From 5da692c20aef08577d900f9a860b5f78a549418e Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Mon, 13 Jul 2026 11:08:47 -0600 Subject: [PATCH 1/2] inject host time into datastream wasm rules engine (SCHY-471) The raw wasm has no clock under wasmtime, so metric-period reset computation trapped (wasm unreachable) and entitled companies wrongly fell back to the flag default. Call setCurrentTimeMillis with the host wall-clock before checkFlagCombined; add a regression test that reproduces the trap and asserts correct evaluation. Co-Authored-By: Claude Opus 4.8 --- lib/schematic/rules_engine.rb | 11 +++++ test/rules_engine_clock_test.rb | 74 +++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 test/rules_engine_clock_test.rb diff --git a/lib/schematic/rules_engine.rb b/lib/schematic/rules_engine.rb index 773c3b6..97c5cfd 100644 --- a/lib/schematic/rules_engine.rb +++ b/lib/schematic/rules_engine.rb @@ -15,6 +15,7 @@ def initialize(logger: nil) @alloc_fn = nil @dealloc_fn = nil @check_flag_fn = nil + @set_time_fn = nil @get_result_json_fn = nil @get_result_json_length_fn = nil @get_version_key_fn = nil @@ -47,6 +48,11 @@ def initialize! @alloc_fn = export_func("alloc") @dealloc_fn = export_func("dealloc") @check_flag_fn = export_func("checkFlagCombined") + # Optional: feed the wasm the current wall-clock time before each check. + # The raw wasm32-unknown-unknown build has no clock under wasmtime + # (SCHY-471); without this, metric-period reset timestamps are omitted. + # Absent on older wasm — hence the safe-navigation call below. + @set_time_fn = @instance.export("setCurrentTimeMillis")&.to_func @get_result_json_fn = export_func("getResultJson") @get_result_json_length_fn = export_func("getResultJsonLength") @get_version_key_fn = export_func("get_version_key_wasm") @@ -80,6 +86,11 @@ def check_flag(flag, company = nil, user = nil) ptr = @alloc_fn.call(json_bytes.bytesize) begin @memory.write(ptr, json_bytes) + + # Supply the host's current time so the engine can compute + # metric-period reset timestamps (SCHY-471). No-op on older wasm. + @set_time_fn&.call((Time.now.to_f * 1000).to_i) + result_len = @check_flag_fn.call(ptr, json_bytes.bytesize) raise "WASM checkFlagCombined returned error" if result_len.negative? diff --git a/test/rules_engine_clock_test.rb b/test/rules_engine_clock_test.rb new file mode 100644 index 0000000..29ceedf --- /dev/null +++ b/test/rules_engine_clock_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative "test_helper" +require "minitest/autorun" + +# Regression for SCHY-471. +# +# A company-override entitlement rule whose metric condition uses a +# calendar/billing metric period drives the engine into the metric-period-reset +# code path, which needs a wall clock. On the raw wasm32-unknown-unknown build +# that path used to trap (+wasm unreachable+); the flag then wrongly fell back to +# its default value for a company that is legitimately entitled. The host now +# injects the current time via +setCurrentTimeMillis+ so this evaluates cleanly. +class RulesEngineClockTest < Minitest::Test + def setup + @engine = Schematic::RulesEngine.new + @engine.initialize! + # The wasm binary is fetched via scripts/download-wasm.sh and may be absent + # (or the wasmtime gem missing) in some environments — skip rather than fail. + skip "WASM rules engine unavailable (wasm binary or wasmtime gem missing)" unless @engine.initialized? + end + + def flag + { + "id" => "flag1", "key" => "mcp-access", "account_id" => "acc_1", + "environment_id" => "env_1", "default_value" => false, "rules" => [] + } + end + + # Usage 40 < allocation 100, so the override grants the feature. + def entitled_company + { + "id" => "co_entitled", "account_id" => "acc_1", "environment_id" => "env_1", + "keys" => { "id" => "co_entitled" }, + "metrics" => [{ + "account_id" => "acc_1", "environment_id" => "env_1", "company_id" => "co_entitled", + "event_subtype" => "api-calls", "period" => "current_month", + "month_reset" => "billing_cycle", "value" => 40, "created_at" => "2023-01-01T00:00:00Z" + }], + "rules" => [{ + "id" => "rule_override", "flag_id" => "flag1", "account_id" => "acc_1", + "environment_id" => "env_1", "name" => "Company Override", + "rule_type" => "company_override", "priority" => 0, "value" => true, + "condition_groups" => [], + "conditions" => [ + { + "id" => "cond_company", "account_id" => "acc_1", "environment_id" => "env_1", + "condition_type" => "company", "operator" => "eq", + "resource_ids" => ["co_entitled"], "trait_value" => "" + }, + { + "id" => "cond_metric", "account_id" => "acc_1", "environment_id" => "env_1", + "condition_type" => "metric", "operator" => "lt", "event_subtype" => "api-calls", + "metric_value" => 100, "metric_period" => "current_month", + "metric_period_month_reset" => "billing_cycle", "trait_value" => "100" + } + ] + }] + } + end + + def test_billing_metric_override_evaluates_without_trapping + result = @engine.check_flag(flag, entitled_company) + assert_equal true, result[:value], + "company override should grant the flag, not fall back to default false" + assert_match(/override/i, result[:reason].to_s) + end + + def test_billing_metric_override_populates_reset_at + result = @engine.check_flag(flag, entitled_company) + refute_nil result[:feature_usage_reset_at], + "reset-at should be computed from the injected host time" + end +end From ee61788c5a83fce5dd5768b2536391459550d176 Mon Sep 17 00:00:00 2001 From: Christopher Brady Date: Tue, 14 Jul 2026 08:27:10 -0600 Subject: [PATCH 2/2] fix rubocop offenses in clock regression test (SCHY-471) Use assert over assert_equal true, and add the required blank lines before assertion methods (Minitest/AssertTruthy, Minitest/EmptyLineBeforeAssertionMethods). Co-Authored-By: Claude Opus 4.8 --- test/rules_engine_clock_test.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/rules_engine_clock_test.rb b/test/rules_engine_clock_test.rb index 29ceedf..8711b5e 100644 --- a/test/rules_engine_clock_test.rb +++ b/test/rules_engine_clock_test.rb @@ -61,13 +61,14 @@ def entitled_company def test_billing_metric_override_evaluates_without_trapping result = @engine.check_flag(flag, entitled_company) - assert_equal true, result[:value], - "company override should grant the flag, not fall back to default false" + + assert result[:value], "company override should grant the flag, not fall back to default false" assert_match(/override/i, result[:reason].to_s) end def test_billing_metric_override_populates_reset_at result = @engine.check_flag(flag, entitled_company) + refute_nil result[:feature_usage_reset_at], "reset-at should be computed from the injected host time" end