Skip to content

feat(ladder): add per-account config + schema foundation (default-off)#1355

Open
cristim wants to merge 5 commits into
mainfrom
feat/ladder-schema
Open

feat(ladder): add per-account config + schema foundation (default-off)#1355
cristim wants to merge 5 commits into
mainfrom
feat/ladder-schema

Conversation

@cristim

@cristim cristim commented Jul 4, 2026

Copy link
Copy Markdown
Member

Summary

Foundation for commitment laddering (phase-3 PR-1 of tracker #1333). Flag-gated and default-off: nothing runs until an operator enables both the global kill-switch and a per-account config. No scheduled task, email, or execution path is wired here (those land in later phase-3 PRs).

Surface:

  • Migrations 000079/080/081: ladder_configs (per account x provider), ladder_runs (immutable audit), ladder_tranches (timed purchase slices with a run_id trace to ladder_runs); global_config.laddering_enabled kill-switch; ladder_run_id FK on purchase_executions and ri_exchange_history. All reversible.
  • Config store: GetLadderConfigs / GetLadderConfig / UpsertLadderConfig, plus StoreInterface widening and mock coverage.
  • API: GET/PUT /api/ladder/configs with RBAC and fail-loud validation. Out-of-range numerics are rejected with 400 and never silently defaulted; absent keys take their default, and an explicit buffer_fraction=0 is honored as "no buffer".
  • Settings UI: a Commitment Laddering section (global toggle + per-account editor) rendered into the Purchasing panel.

Money-path fields are nullable (NULL, not 0) and typed enums are validated against pkg/ladder rather than redefined.

Verification

  • go build ./..., go vet ./... (whole module): pass
  • golangci-lint --new-from-rev=origin/main on internal/api, internal/config, internal/server, internal/analytics: 0 new issues
  • go test ./... (whole root module): pass, 0 failures
  • Frontend: tsc typecheck, eslint, and jest (78 suites, 2557 passed, 1 skipped): pass
  • Migrations up/down/up reversibility on Docker PostgreSQL 16 (migrate v4.17.1): all objects created, dropped, and re-created; ladder_tranches.run_id -> ladder_runs FK verified; final version 81, not dirty
  • New tests: table-driven LadderConfigDB.Validate() bounds coverage, handler tests for the fail-loud defaulting contract, and frontend jsdom tests for renderConfigTable XSS escaping and the saveLadderConfig max-hourly guard

Out of scope (follow-up phase-3 PRs)

ladder_run scheduled task, email-approval mode, auto-approve mode, run-history UI, and the Terraform EventBridge tick.

Part of #1336 (phase-3 PR-1)
Refs #1333

Summary by CodeRabbit

  • New Features
    • Added Commitment Laddering settings in Admin → Purchasing, including a global on/off kill-switch and per-account/provider ladder configuration management (view, add, edit, save).
  • Bug Fixes
    • Hardened ladder settings rendering against injected content and improved save validation for numeric fields and blank inputs.
    • Ensured the global kill-switch is preserved when saving partial global configuration updates.
  • Tests
    • Added/extended automated coverage for ladder settings rendering, validation, persistence behavior, and partial-update preservation.

Foundation for commitment laddering (phase-3 PR-1). Flag-gated and
default-off: nothing runs until an operator enables both the global
kill-switch and a per-account config. No scheduled task, email, or
execution path is wired here (those land in later phase-3 PRs).

Surface:
- Migrations 000079/080/081: ladder_configs (per account x provider),
  ladder_runs (immutable audit), ladder_tranches (timed purchase slices
  with a run_id trace to ladder_runs); global_config.laddering_enabled
  kill-switch; ladder_run_id FK on purchase_executions and
  ri_exchange_history. All reversible (up/down verified on PG16).
- Config store: GetLadderConfigs / GetLadderConfig / UpsertLadderConfig
  plus StoreInterface widening and mock coverage.
- API: GET/PUT /api/ladder/configs with RBAC and fail-loud validation
  (out-of-range numerics rejected 400, never silently defaulted; absent
  keys default, explicit buffer_fraction=0 honored as no-buffer).
- Settings UI: Commitment Laddering section (global toggle + per-account
  editor) rendered into the Purchasing panel.

Money-path fields are nullable (NULL, not 0) and typed enums are validated
against pkg/ladder rather than redefined.

Part of #1336 (phase-3 PR-1)
Refs #1333
@cristim cristim added priority/p1 Next up; this sprint severity/medium Moderate harm urgency/this-quarter Within the quarter impact/many Affects most users effort/xl Multi-week / refactor type/feat New capability triaged Item has been triaged labels Jul 4, 2026
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

No new commits to review since the last review.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1ec9e6bc-79b0-4609-b45a-d2f9c9b45e99

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds commitment-laddering support across database schema, backend storage/validation/API, and frontend settings UI, including a global laddering_enabled switch and per-account/provider ladder configuration editing.

Changes

Commitment Laddering feature

Layer / File(s) Summary
Database migrations for ladder tables and kill-switch
internal/database/postgres/migrations/000079_ladder_configs.*, 000080_ladder_runs.*, 000081_ladder_tranches.*
Adds and rolls back the laddering kill-switch column and the ladder_configs, ladder_runs, and ladder_tranches tables with indexes, triggers, and foreign keys.
Config types and validation for LadderConfigDB
internal/config/types.go, internal/config/validation.go, internal/config/validation_ladder_test.go
Adds the laddering enable flag, ladder config persistence type, default constants, and validation logic with coverage, bounds, enum, and ramp schedule checks.
Store interface and Postgres persistence for ladder configs
internal/config/interfaces.go, internal/config/store_postgres.go, internal/config/store_postgres_ladder.go, internal/config/store_postgres_pgxmock_test.go
Extends the config store interface, loads and saves the laddering kill-switch, and implements ladder config listing, lookup, upsert, and pgxmock coverage.
Mock store updates for ladder methods
internal/mocks/stores.go, internal/analytics/collector_test.go, internal/server/test_helpers_test.go
Adds ladder config mock methods to shared test stores used by analytics and server tests.
API handlers and routes for ladder configs and kill-switch preservation
internal/api/handler_ladder.go, internal/api/handler_ladder_test.go, internal/api/router.go, internal/api/handler_config.go, internal/api/handler_config_test.go
Adds ladder config read and upsert handlers, router registrations, kill-switch merge behavior, and handler tests for preservation and validation.
Frontend API bindings and types for ladder
frontend/src/api/ladder.ts, frontend/src/api/index.ts, frontend/src/api/types.ts, frontend/src/types.ts, frontend/src/api/settings.ts
Adds ladder API contracts, request helpers, barrel exports, laddering-enabled fields in frontend config types, and partial config update support.
Frontend Commitment Laddering settings UI
frontend/src/ladder.ts, frontend/src/index.html, frontend/src/settings.ts, frontend/src/__tests__/ladder.test.ts
Adds the laddering settings section, modal and table rendering, save flow, settings wiring, and tests for escaping and max-hourly validation.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant LadderUI as ladder.ts
  participant Api as api/ladder.ts
  participant Router as internal/api/router.go
  participant Handler as internal/api/handler_ladder.go
  participant Store as config store

  User->>LadderUI: Submit ladder config form
  LadderUI->>LadderUI: validate input and build LadderConfig
  LadderUI->>Api: upsertLadderConfig(cfg)
  Api->>Router: PUT /api/ladder/configs
  Router->>Handler: upsertLadderConfig(req)
  Handler->>Handler: parse JSON, apply defaults, validate
  Handler->>Store: UpsertLadderConfig(cfg)
  Store-->>Handler: persisted config
  Handler-->>Router: response
  Router-->>Api: response body
  Api-->>LadderUI: updated config
  LadderUI->>LadderUI: update cache, rerender table, close modal
Loading

Possibly related issues

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding default-off laddering per-account config and schema foundation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ladder-schema

Comment @coderabbitai help to get the list of available commands.

@cristim

cristim commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

Note on CI: the Build frontend job failure is pre-existing debt, not introduced by this PR. The single blocking eslint error is frontend/src/__tests__/users.test.ts:1861 (no-unused-expressions: expect(...).toBeDefined; missing its ()), which is present verbatim on main and in a file this PR does not touch. The 89 accompanying no-explicit-any entries are non-blocking warnings. This PR's own touched-file eslint is clean and introduces no new frontend lint issues. (Same category as the pre-existing Go Lint #1342 / gosec #1354 debt that keeps main's lint jobs red.)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/api/handler_config.go (1)

105-121: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

GetGlobalConfig failure silently zeroes omitted fields instead of failing loud — undermines the kill-switch preservation guarantee.

When h.config.GetGlobalConfig(ctx) errors, the function returns nil (success) without preserving anything:

existing, gcErr := h.config.GetGlobalConfig(ctx)
if gcErr != nil || existing == nil {
    return nil
}

updateConfig then proceeds to cfg.Validate() and SaveGlobalConfig, persisting whatever zero values the initial json.Unmarshal left in cfg for any omitted key — including LadderingEnabled = false if the request body omitted laddering_enabled (e.g. a General-panel save). This is precisely the "silently reset the kill-switch back to false" scenario the comment on Line 92-94 explicitly says must be avoided, and it also silently resets the older recommendations_cache_stale_hours / recommendations_lookback_days / purchase_delay_hours fields on a transient store read failure. The PR's stated design goal is fail-loud validation on money-path fields, but this path fails silently on a DB error instead of surfacing it.

🐛 Proposed fix — propagate the read error instead of swallowing it
 	existing, gcErr := h.config.GetGlobalConfig(ctx)
-	if gcErr != nil || existing == nil {
+	if gcErr != nil {
+		return fmt.Errorf("failed to load existing config for field preservation: %w", gcErr)
+	}
+	if existing == nil {
 		return nil
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_config.go` around lines 105 - 121, `updateConfig` in
`handlerConfig` is swallowing `GetGlobalConfig` read failures and returning
success, which can persist zero-valued omitted fields and break the kill-switch
preservation behavior. Change the `GetGlobalConfig` handling so `gcErr` is
returned to the caller instead of treated like success, and keep the merge logic
in this function using `existing` only when the read succeeds. This preserves
the omitted-field fallback for `RecommendationsCacheStaleHours`,
`RecommendationsLookbackDays`, `PurchaseDelayHours`, and `LadderingEnabled`
while failing loud on config store errors.
🧹 Nitpick comments (5)
frontend/src/__tests__/ladder.test.ts (1)

13-18: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider adding regression coverage for the kill-switch save payload.

Good coverage for XSS escaping and the max-hourly guard. Once the fetchGlobalConfigForLaddering field-completeness fix (flagged in ladder.ts) lands, consider a test asserting api.updateConfig is called with all persisted Config fields (not just the ones currently listed) when the kill-switch toggle fires, so a future field addition to Config doesn't silently regress into the same zeroing bug.

Want me to draft this test?

Also applies to: 104-134

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/__tests__/ladder.test.ts` around lines 13 - 18, Add regression
coverage around the kill-switch save path in the ladder tests: once the
field-completeness fix in fetchGlobalConfigForLaddering lands, update the ladder
toggle test so it verifies api.updateConfig is invoked with the full persisted
Config payload, not only the currently enumerated fields. Use the existing
ladder.ts save flow and api.updateConfig mock in ladder.test.ts to assert future
Config additions are preserved and not zeroed out by omission.
internal/database/postgres/migrations/000081_ladder_tranches.up.sql (1)

25-31: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Same CHECK-constraint consideration for layer_type, term, payment_option, status.

Consistent with the earlier comments on this same recurring pattern in 000079_ladder_configs.up.sql and 000080_ladder_runs.up.sql, these enum-like TEXT columns have no CHECK constraints. Not blocking given app-layer validation via pkg/ladder, but consider consolidating into constraints across all three tables in a follow-up migration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/database/postgres/migrations/000081_ladder_tranches.up.sql` around
lines 25 - 31, Add CHECK constraints for the enum-like TEXT columns in this
migration’s ladder tranche table definition, matching the same pattern discussed
for the related ladder config/run migrations. Update the CREATE TABLE statement
that defines layer_type, term, payment_option, and status so each is constrained
to its allowed values, and keep the constraint names consistent with the
existing ladder migration naming style.
internal/database/postgres/migrations/000079_ladder_configs.up.sql (1)

19-39: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider CHECK constraints for enum/text and numeric-range columns.

provider, mode, cadence are free-form TEXT with valid values documented only in comments (line 25-26), and target_coverage, buffer_fraction, baseline_percentile, buffer_utilization_threshold, lookback_days, max_actions_per_run have no bound checks. Since pkg/ladder validates these at the application layer, this is not blocking, but adding CHECK constraints would guard against bad data from future direct writes, ad-hoc scripts, or bugs bypassing app validation on a table that ultimately drives real financial commitments.

💡 Example CHECK constraints
     provider                        TEXT            NOT NULL,
     enabled                         BOOLEAN         NOT NULL DEFAULT false,
-    mode                            TEXT            NOT NULL,   -- email_approval | auto_approve
-    cadence                         TEXT            NOT NULL,   -- daily | weekly
+    mode                            TEXT            NOT NULL
+                                                    CHECK (mode IN ('email_approval', 'auto_approve')),
+    cadence                         TEXT            NOT NULL
+                                                    CHECK (cadence IN ('daily', 'weekly')),
     target_coverage                 NUMERIC(5,2)    NOT NULL,
-    buffer_fraction                 NUMERIC(5,4)    NOT NULL,
+    buffer_fraction                 NUMERIC(5,4)    NOT NULL CHECK (buffer_fraction BETWEEN 0 AND 1),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/database/postgres/migrations/000079_ladder_configs.up.sql` around
lines 19 - 39, Add CHECK constraints to the ladder_configs table so the database
enforces the same value rules already implied by pkg/ladder validation. Update
the migration for ladder_configs to restrict provider, mode, and cadence to the
allowed text values, and add range checks for target_coverage, buffer_fraction,
baseline_percentile, buffer_utilization_threshold, lookback_days, and
max_actions_per_run. Keep the constraints close to the existing CREATE TABLE
definition so future direct writes or bypasses cannot insert invalid
ladder_configs data.
internal/database/postgres/migrations/000080_ladder_runs.up.sql (1)

24-24: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Same CHECK-constraint consideration applies to status.

Consistent with the earlier comment on internal/database/postgres/migrations/000079_ladder_configs.up.sql (lines 19-39), status is a free-form TEXT enum documented only in a comment. Consider a CHECK constraint against the documented lifecycle values for defense-in-depth.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/database/postgres/migrations/000080_ladder_runs.up.sql` at line 24,
The status column in the ladder runs migration is still a free-form TEXT value,
so add a CHECK constraint in the same migration to restrict it to the documented
lifecycle states. Update the ladder run table definition in
000080_ladder_runs.up.sql so the constraint matches the statuses used by the
ladder run model and stays consistent with the earlier ladder config migration.
internal/config/validation.go (1)

558-582: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share the ladder bounds checks to avoid drift. internal/config.LadderConfigDB.Validate() mirrors pkg/ladder.LadderConfig’s range checks for coverage, buffer fraction, baseline percentile, and run limits, so the two paths can diverge over time. Exporting a small shared helper (or moving the bounds logic into one shared validator) would keep both configs aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/validation.go` around lines 558 - 582,
`LadderConfigDB.Validate` is duplicating the ladder bounds logic that already
exists in `pkg/ladder.LadderConfig`, which can drift over time. Refactor the
range checks for coverage, buffer fraction, baseline percentile, and run limits
into a shared helper/validator and have both `LadderConfigDB.Validate` and the
`pkg/ladder` validation path call it so they use the same invariants.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/src/ladder.ts`:
- Line 26: The module-level save guard is shared between two unrelated flows,
causing the kill-switch toggle and the per-account modal save to block or revert
each other. Split the current `saveInFlight` state in `ladder.ts` into separate
flags for each flow, then update `wireKillSwitchToggle` to use
`killSwitchSaveInFlight` and `saveLadderConfig` to use `configModalSaveInFlight`
so each operation is independently gated.
- Around line 343-382: The payload built in fetchGlobalConfigForLaddering is
missing persisted GlobalConfig fields, so the kill-switch save can overwrite
unrelated settings. Update this helper to round-trip every backend-stored field
handled by updateConfig/SaveGlobalConfig, including approval_required,
default_ramp_schedule, and the ri_exchange_* values, alongside the existing
laddering_enabled and other fields. Keep the shape aligned with api.Config so
toggling laddering preserves all other global settings.

In `@internal/database/postgres/migrations/000080_ladder_runs.up.sql`:
- Around line 42-46: The ladder runs approval schema currently stores
approval_token as plaintext TEXT, which should be changed to a hashed-token
storage pattern before the approval flow depends on it. Update the migration
around the approval_token and approval_token_expires_at columns so the database
only persists a hash (using a dedicated hashed column or equivalent naming in
this migration), and keep the raw token for email delivery only in application
logic. Preserve the existing ladder_runs schema fields like approved_by,
cancelled_by, and fire_at while making the approval token storage safe by
default.

---

Outside diff comments:
In `@internal/api/handler_config.go`:
- Around line 105-121: `updateConfig` in `handlerConfig` is swallowing
`GetGlobalConfig` read failures and returning success, which can persist
zero-valued omitted fields and break the kill-switch preservation behavior.
Change the `GetGlobalConfig` handling so `gcErr` is returned to the caller
instead of treated like success, and keep the merge logic in this function using
`existing` only when the read succeeds. This preserves the omitted-field
fallback for `RecommendationsCacheStaleHours`, `RecommendationsLookbackDays`,
`PurchaseDelayHours`, and `LadderingEnabled` while failing loud on config store
errors.

---

Nitpick comments:
In `@frontend/src/__tests__/ladder.test.ts`:
- Around line 13-18: Add regression coverage around the kill-switch save path in
the ladder tests: once the field-completeness fix in
fetchGlobalConfigForLaddering lands, update the ladder toggle test so it
verifies api.updateConfig is invoked with the full persisted Config payload, not
only the currently enumerated fields. Use the existing ladder.ts save flow and
api.updateConfig mock in ladder.test.ts to assert future Config additions are
preserved and not zeroed out by omission.

In `@internal/config/validation.go`:
- Around line 558-582: `LadderConfigDB.Validate` is duplicating the ladder
bounds logic that already exists in `pkg/ladder.LadderConfig`, which can drift
over time. Refactor the range checks for coverage, buffer fraction, baseline
percentile, and run limits into a shared helper/validator and have both
`LadderConfigDB.Validate` and the `pkg/ladder` validation path call it so they
use the same invariants.

In `@internal/database/postgres/migrations/000079_ladder_configs.up.sql`:
- Around line 19-39: Add CHECK constraints to the ladder_configs table so the
database enforces the same value rules already implied by pkg/ladder validation.
Update the migration for ladder_configs to restrict provider, mode, and cadence
to the allowed text values, and add range checks for target_coverage,
buffer_fraction, baseline_percentile, buffer_utilization_threshold,
lookback_days, and max_actions_per_run. Keep the constraints close to the
existing CREATE TABLE definition so future direct writes or bypasses cannot
insert invalid ladder_configs data.

In `@internal/database/postgres/migrations/000080_ladder_runs.up.sql`:
- Line 24: The status column in the ladder runs migration is still a free-form
TEXT value, so add a CHECK constraint in the same migration to restrict it to
the documented lifecycle states. Update the ladder run table definition in
000080_ladder_runs.up.sql so the constraint matches the statuses used by the
ladder run model and stays consistent with the earlier ladder config migration.

In `@internal/database/postgres/migrations/000081_ladder_tranches.up.sql`:
- Around line 25-31: Add CHECK constraints for the enum-like TEXT columns in
this migration’s ladder tranche table definition, matching the same pattern
discussed for the related ladder config/run migrations. Update the CREATE TABLE
statement that defines layer_type, term, payment_option, and status so each is
constrained to its allowed values, and keep the constraint names consistent with
the existing ladder migration naming style.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b68e2785-9664-4d88-8ef2-6b7d1ca4b0d3

📥 Commits

Reviewing files that changed from the base of the PR and between 60526ce and 25525bb.

📒 Files selected for processing (29)
  • frontend/src/__tests__/ladder.test.ts
  • frontend/src/api/index.ts
  • frontend/src/api/ladder.ts
  • frontend/src/api/types.ts
  • frontend/src/index.html
  • frontend/src/ladder.ts
  • frontend/src/settings.ts
  • frontend/src/types.ts
  • internal/analytics/collector_test.go
  • internal/api/handler_config.go
  • internal/api/handler_config_test.go
  • internal/api/handler_ladder.go
  • internal/api/handler_ladder_test.go
  • internal/api/router.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_ladder.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/config/validation.go
  • internal/config/validation_ladder_test.go
  • internal/database/postgres/migrations/000079_ladder_configs.down.sql
  • internal/database/postgres/migrations/000079_ladder_configs.up.sql
  • internal/database/postgres/migrations/000080_ladder_runs.down.sql
  • internal/database/postgres/migrations/000080_ladder_runs.up.sql
  • internal/database/postgres/migrations/000081_ladder_tranches.down.sql
  • internal/database/postgres/migrations/000081_ladder_tranches.up.sql
  • internal/mocks/stores.go
  • internal/server/test_helpers_test.go

Comment thread frontend/src/ladder.ts Outdated
Comment thread frontend/src/ladder.ts Outdated
Comment thread internal/database/postgres/migrations/000080_ladder_runs.up.sql Outdated
cristim added 3 commits July 4, 2026 04:17
The commitment-laddering kill-switch toggle sends a partial PUT to
/api/config carrying only the field it changes. updateConfig unmarshalled
that body into a zero-value GlobalConfig and only restored a handful of
enumerated fields, so every omitted field (approval_required,
default_ramp_schedule, and all six ri_exchange_* automation settings) was
written back as its zero value, wiping the user's stored configuration.

updateConfig now loads the stored GlobalConfig and json.Unmarshals the
request body over a copy of it: keys present in the JSON update their
field, keys absent retain the persisted value, for every field with no
per-field enumeration. Malformed bodies are rejected up front; a read
error fails loud rather than merging over a zero base.

Deletes the fragile preserveOmittedRecommendationFields helper and adds a
regression test that PUTs only {"laddering_enabled": true} and asserts the
omitted fields survive on the config handed to SaveGlobalConfig.
Split the shared module-level saveInFlight flag into killSwitchSaveInFlight
and configModalSaveInFlight so the kill-switch toggle and the per-account
modal save, which hit unrelated endpoints, no longer block or revert each
other.

Now that the backend merges partial config PUTs, the kill-switch sends only
{ laddering_enabled } instead of rebuilding and re-sending the whole config
(which was fragile and silently dropped fields it did not enumerate). The
frontend updateConfig client accepts Partial<Config>, and the
fetchGlobalConfigForLaddering helper is removed.
Rename ladder_runs.approval_token to approval_token_hash in migration
000080 and document that it holds the SHA-256 hex of the run approval
token: the raw token is sent only in the approval email link, and the
email-approval PR (phase-3 PR-3) must hash-on-write and constant-time
compare against this column.

The pre-existing raw approval_token columns on purchase_executions and
ri_exchange_history are out of scope here and tracked in #1356.
@cristim

cristim commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
frontend/src/ladder.ts (1)

421-436: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Cleared numeric inputs silently become 0, not the documented default.

?? only falls back when the left side is null/undefined — an <input>'s .value is always a string, so clearing the field yields '', and Number('') is 0, not the intended default. This affects target_coverage, buffer_fraction, baseline_percentile, lookback_days, buffer_utilization_threshold, and max_actions_per_run. maxHourlyRaw just above (Lines 405-410) correctly special-cases the empty string — the other fields don't, so a cleared field silently submits 0 for a per-account, money-adjacent ladder setting instead of falling back to the shown default.

🔧 Proposed fix: treat empty string as "use default" for numeric fields
+  const numOrDefault = (id: string, fallback: number): number => {
+    const raw = (document.getElementById(id) as HTMLInputElement | null)?.value?.trim() ?? '';
+    return raw === '' ? fallback : Number(raw);
+  };
+
   const cfg: api.LadderConfig = {
     ...
-    target_coverage: Number((document.getElementById('ladder-cfg-target-coverage') as HTMLInputElement | null)?.value ?? '100'),
-    buffer_fraction: Number((document.getElementById('ladder-cfg-buffer-fraction') as HTMLInputElement | null)?.value ?? '0.10'),
-    baseline_percentile: Number((document.getElementById('ladder-cfg-baseline-percentile') as HTMLInputElement | null)?.value ?? '5'),
-    lookback_days: Number((document.getElementById('ladder-cfg-lookback-days') as HTMLInputElement | null)?.value ?? '30'),
-    buffer_utilization_threshold: Number((document.getElementById('ladder-cfg-buf-util-threshold') as HTMLInputElement | null)?.value ?? '90'),
+    target_coverage: numOrDefault('ladder-cfg-target-coverage', 100),
+    buffer_fraction: numOrDefault('ladder-cfg-buffer-fraction', 0.10),
+    baseline_percentile: numOrDefault('ladder-cfg-baseline-percentile', 5),
+    lookback_days: numOrDefault('ladder-cfg-lookback-days', 30),
+    buffer_utilization_threshold: numOrDefault('ladder-cfg-buf-util-threshold', 90),
     max_hourly_commit_per_run: maxHourly,
-    max_actions_per_run: Number((document.getElementById('ladder-cfg-max-actions') as HTMLInputElement | null)?.value ?? '10'),
+    max_actions_per_run: numOrDefault('ladder-cfg-max-actions', 10),
     ramp_schedule: rampSchedule,
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/ladder.ts` around lines 421 - 436, The numeric fields in the
ladder config builder are treating cleared inputs as 0 instead of the intended
defaults. Update the cfg assembly in ladder.ts so the numeric reads for
target_coverage, buffer_fraction, baseline_percentile, lookback_days,
buffer_utilization_threshold, and max_actions_per_run explicitly treat an empty
string as “use the default” before calling Number(), similar to how maxHourlyRaw
is handled above. Keep the fix localized to the LadderConfig construction logic
and preserve the existing defaults shown in the UI.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_config.go`:
- Around line 61-89: The current mergeGlobalConfigUpdate/updateConfig flow can
lose concurrent partial PUT changes because it reads the stored GlobalConfig and
later writes the merged snapshot without any concurrency guard. Fix this by
adding a row lock or optimistic version check around the read/merge/write
sequence in mergeGlobalConfigUpdate and the save path in updateConfig so
overlapping updates cannot overwrite each other silently. Ensure the
locking/versioning is applied to the global_config record used by Handler.

---

Outside diff comments:
In `@frontend/src/ladder.ts`:
- Around line 421-436: The numeric fields in the ladder config builder are
treating cleared inputs as 0 instead of the intended defaults. Update the cfg
assembly in ladder.ts so the numeric reads for target_coverage, buffer_fraction,
baseline_percentile, lookback_days, buffer_utilization_threshold, and
max_actions_per_run explicitly treat an empty string as “use the default” before
calling Number(), similar to how maxHourlyRaw is handled above. Keep the fix
localized to the LadderConfig construction logic and preserve the existing
defaults shown in the UI.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 412386d5-c3af-4d04-870b-957be617449a

📥 Commits

Reviewing files that changed from the base of the PR and between 25525bb and 79e75fe.

📒 Files selected for processing (5)
  • frontend/src/api/settings.ts
  • frontend/src/ladder.ts
  • internal/api/handler_config.go
  • internal/api/handler_config_test.go
  • internal/database/postgres/migrations/000080_ladder_runs.up.sql
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/database/postgres/migrations/000080_ladder_runs.up.sql
  • internal/api/handler_config_test.go

Comment thread internal/api/handler_config.go Outdated
The earlier F2 merge fix made updateConfig a read-modify-write across two
separate statements (GetGlobalConfig then SaveGlobalConfig), so two
overlapping partial PUTs could each read the same stored config and the
later save would silently drop the earlier change (lost update / TOCTOU).
The laddering kill-switch toggle and a Settings save racing is a realistic
trigger.

Add UpdateGlobalConfigAtomic(ctx, apply) on the config store: it opens one
transaction, takes a transaction-scoped advisory lock, reads the current
config, runs the caller's in-place apply (updateConfig passes a closure
that json.Unmarshals the request body over the loaded config and validates
it), and upserts the result in the same transaction. Concurrent partial
PUTs are serialized, so the later writer reads the earlier committed state
and no update is lost.

Uses an advisory lock rather than SELECT FOR UPDATE because the
global_config singleton may not exist yet on first config, and a row-level
lock cannot lock a non-existent row; the advisory lock serializes the first
insert too. The lock key is derived like the scheduled-task locks
(FNV-64a of a namespaced string) so it cannot collide with those or the
migration lock.

Behavior preserved: first-config defaults, malformed-body 400 before any DB
work, present-zero-wins / absent-preserved merge, and best-effort service
propagation. GetGlobalConfig/SaveGlobalConfig were refactored into
tx-capable helpers; the obsolete mergeGlobalConfigUpdate helper is removed.

Tests: a store-level pgxmock test asserting the BEGIN -> advisory lock ->
SELECT -> UPSERT -> COMMIT ordering and field round-trip; an
apply-error-rolls-back test; a handler test asserting the atomic path is
taken; and a -race regression test where two overlapping partial PUTs
touching different fields both survive (WaitGroup + channel, no sleeps).
@cristim

cristim commented Jul 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/xl Multi-week / refactor impact/many Affects most users priority/p1 Next up; this sprint severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant