Skip to content

feat: add deterministic PTC rollout percentage gating#2266

Merged
nettee merged 1 commit into
mainfrom
ptc-gray-pr
Mar 9, 2026
Merged

feat: add deterministic PTC rollout percentage gating#2266
nettee merged 1 commit into
mainfrom
ptc-gray-pr

Conversation

@nettee

@nettee nettee commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds deterministic percentage-based rollout control for PTC while preserving existing mode, user, and toolset gating behavior.

Changes

  • Added PTC_ROLLOUT_PERCENT and PTC_ROLLOUT_SALT to app/PTC configuration with clamped defaults
  • Implemented deterministic per-user rollout bucketing and applied rollout checks after mode and toolset eligibility
  • Expanded unit tests in ptc-config.spec.ts for 0/100 boundaries, deterministic behavior, and salt-based rebucketing
  • Updated PTC spec docs to clarify env-var priority rules and rollout usage examples

Summary by CodeRabbit

  • New Features

    • Deterministic rollout gate for controlled gradual feature rollouts with stable per-user bucketing
    • New rollout configuration: rollout percentage (0–100, default 100) and rollout salt
  • Documentation

    • Updated PTC docs with priority-driven evaluation, rollout behavior, and rollout scenarios
  • Tests

    • Added unit tests covering rollout bucketing, edge cases (0%/100%), determinism, and salt changes

@coderabbitai

coderabbitai Bot commented Mar 9, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds deterministic per-user rollout gating for PTC: two new config fields (rolloutPercent, rolloutSalt), a stable hash-based bucket computation, a public isPtcEnabledForRollout(uid, config) function, and integration of the rollout gate into the existing toolset eligibility flow with tests and docs updated.

Changes

Cohort / File(s) Summary
Configuration
apps/api/src/modules/config/app.config.ts
Added ptc.rolloutPercent and ptc.rolloutSalt environment-backed properties with defaults.
Core Rollout Logic
apps/api/src/modules/tool/ptc/ptc-config.ts
Extended PtcConfig with rolloutPercent and rolloutSalt; added deterministic bucket computation (hash-based) and isPtcEnabledForRollout; integrated rollout gate into isPtcEnabledForToolsets; updated getPtcConfig to parse new fields.
Test Coverage
apps/api/src/modules/tool/ptc/ptc-config.spec.ts
Added tests for isPtcEnabledForRollout (determinism, salt sensitivity, 0%/100% edge cases) and propagated rollout fields through existing PTC tests.
Specification
specs/change/20260309-ptc-rollout-percentage-priority/spec.md
New spec describing goals, env vars (PTC_ROLLOUT_PERCENT, PTC_ROLLOUT_SALT), priority ordering, deterministic bucketing, and implementation/testing plan.
Documentation
specs/current/ptc/PTC.md
Updated PTC documentation to include rollout percentage priority, bucketing behavior, and revised evaluation order and scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • mrcfps
  • alchemistklk

Poem

🐰 I hashed the uid with a dash and a salt,
I hopped through buckets without any fault,
From zero to hundred the rollout will run,
Deterministic carrots for everyone! 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add deterministic PTC rollout percentage gating' accurately and concisely summarizes the main change: adding deterministic rollout percentage gating to the PTC module.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch ptc-gray-pr

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.

❤️ Share

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Mar 9, 2026

Copy link
Copy Markdown

Deploying refly-branch-test with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6e4e5f1
Status: ✅  Deploy successful!
Preview URL: https://3275db19.refly-branch-test.pages.dev
Branch Preview URL: https://ptc-gray-pr.refly-branch-test.pages.dev

View logs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (5)
apps/api/src/modules/config/app.config.ts (1)

264-265: Consider using Number.parseInt for consistency with other numeric configs in this file.

The rest of the file uses Number.parseInt() for integer parsing (e.g., PORT, WS_PORT, REDIS_PORT), but rolloutPercent uses Number(). While both work, Number.parseInt is more explicit for integer values and aligns with the existing pattern.

Additionally, if PTC_ROLLOUT_PERCENT is set to an invalid value (e.g., "abc"), Number() returns NaN. The downstream clamping in getPtcConfig won't catch this (Math.max(0, NaN) returns NaN), though the behavior is fail-safe (rollout disabled). For robustness, consider adding || 100 as a fallback.

♻️ Optional: align with existing patterns
-    rolloutPercent: Number(process.env.PTC_ROLLOUT_PERCENT ?? 100),
+    rolloutPercent: Number.parseInt(process.env.PTC_ROLLOUT_PERCENT) || 100,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/config/app.config.ts` around lines 264 - 265, Change
parsing of the PTC_ROLLOUT_PERCENT env var to use Number.parseInt to match other
integer configs: replace the Number(...) usage for rolloutPercent with
Number.parseInt(process.env.PTC_ROLLOUT_PERCENT ?? '100', 10) (or equivalent)
and include a fallback (|| 100) so invalid values produce the default; ensure
this aligns with the downstream getPtcConfig behavior and references
rolloutPercent and PTC_ROLLOUT_PERCENT in the change.
apps/api/src/modules/tool/ptc/ptc-config.ts (2)

82-86: NaN from invalid config passes through clamping unchanged.

If configService.get<number>('ptc.rolloutPercent') returns NaN (from invalid env var parsing upstream), the Math.min/Math.max clamping won't catch it since Math.max(0, NaN) returns NaN.

The behavior is fail-safe (NaN effectively disables rollout since bucket < NaN is always false), but for defensive coding, consider adding an explicit isNaN check with a fallback to the default.

🛡️ Optional defensive fix
- const rolloutPercent = Math.min(
-   100,
-   Math.max(0, configService.get<number>('ptc.rolloutPercent') ?? 100),
- );
+ const rawPercent = configService.get<number>('ptc.rolloutPercent') ?? 100;
+ const rolloutPercent = Number.isNaN(rawPercent)
+   ? 100
+   : Math.min(100, Math.max(0, rawPercent));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/tool/ptc/ptc-config.ts` around lines 82 - 86, The
computed rolloutPercent uses Math.min/Math.max on
configService.get<number>('ptc.rolloutPercent') but a NaN value will pass
through; update the logic where rolloutPercent is set to explicitly validate the
fetched value (from configService.get<number>('ptc.rolloutPercent')) with
Number.isFinite or Number.isNaN and fall back to the default 100 before
clamping, so rolloutPercent ends up as a finite number between 0 and 100; keep
the existing rolloutSalt logic unchanged.

135-147: Clarify in JSDoc that this function assumes prior eligibility checks.

The exported isPtcEnabledForRollout could be called directly by other code without the preceding user/toolset checks. Consider enhancing the JSDoc to clarify that this function is typically called after isPtcEnabledForUser and isToolsetAllowed checks, or that it only determines rollout eligibility (not full PTC eligibility).

📝 Suggested JSDoc enhancement
 /**
  * Check if a user falls within the rollout percentage.
  * Users are bucketed deterministically so their assignment is stable.
+ *
+ * Note: This only checks rollout eligibility. For full PTC eligibility,
+ * use `isPtcEnabledForToolsets` which applies mode, user, toolset, and
+ * rollout checks in priority order.
  *
  * `@param` uid - The user ID
  * `@param` config - PTC configuration
  * `@returns` true if the user is within the rollout percentage
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/tool/ptc/ptc-config.ts` around lines 135 - 147, The
JSDoc for isPtcEnabledForRollout should state that it only computes
deterministic rollout membership and does not perform other eligibility checks;
update the comment on isPtcEnabledForRollout to clarify it assumes prior checks
like isPtcEnabledForUser and isToolsetAllowed have been performed and that it
only uses computeRolloutBucket and PtcConfig.rolloutSalt/rolloutPercent to
decide rollout inclusion.
apps/api/src/modules/tool/ptc/ptc-config.spec.ts (2)

263-301: Consider adding edge case tests for rollout percentage clamping.

The test suite covers core rollout scenarios well (0%, 100%, determinism, salt changes). However, there are no tests verifying that getPtcConfig correctly clamps out-of-range values (e.g., -100, 150100). This would validate the clamping logic in getPtcConfig and document expected behavior.

🧪 Suggested additional test cases
it('should clamp rolloutPercent below 0 to 0', () => {
  mockConfigService.get = jest.fn((key: string) => {
    const configs: Record<string, string | number> = {
      'ptc.mode': 'on',
      'ptc.debug': '',
      'ptc.userAllowlist': '',
      'ptc.toolsetAllowlist': '',
      'ptc.toolsetBlocklist': '',
      'ptc.rolloutPercent': -10,
      'ptc.rolloutSalt': 'test-salt',
    };
    return configs[key];
  });

  const config = getPtcConfig(mockConfigService as ConfigService);
  expect(config.rolloutPercent).toBe(0);
});

it('should clamp rolloutPercent above 100 to 100', () => {
  mockConfigService.get = jest.fn((key: string) => {
    const configs: Record<string, string | number> = {
      'ptc.mode': 'on',
      'ptc.debug': '',
      'ptc.userAllowlist': '',
      'ptc.toolsetAllowlist': '',
      'ptc.toolsetBlocklist': '',
      'ptc.rolloutPercent': 150,
      'ptc.rolloutSalt': 'test-salt',
    };
    return configs[key];
  });

  const config = getPtcConfig(mockConfigService as ConfigService);
  expect(config.rolloutPercent).toBe(100);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/tool/ptc/ptc-config.spec.ts` around lines 263 - 301, Add
tests to verify getPtcConfig clamps out-of-range rolloutPercent values: mock the
ConfigService (mockConfigService.get) to return rolloutPercent -10 and assert
getPtcConfig(...).rolloutPercent === 0, and similarly return 150 and assert ===
100; reference getPtcConfig and the rolloutPercent property in your new test
cases to ensure clamping behavior is validated.

75-89: Add missing rollout fields to debugMode test mocks for consistency.

Several debugMode test cases (lines 75-137) define mock configs without rolloutPercent and rolloutSalt. While the code handles missing values via defaults, adding these fields would maintain consistency with the updated test patterns elsewhere in this file.

♻️ Example fix for one test case
 mockConfigService.get = jest.fn((key: string) => {
-  const configs: Record<string, string> = {
+  const configs: Record<string, string | number> = {
     'ptc.mode': 'on',
     'ptc.debug': 'true',
     'ptc.userAllowlist': '',
     'ptc.toolsetAllowlist': '',
     'ptc.toolsetBlocklist': '',
+    'ptc.rolloutPercent': 100,
+    'ptc.rolloutSalt': 'ptc-rollout',
   };
   return configs[key];
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/tool/ptc/ptc-config.spec.ts` around lines 75 - 89, The
test for parsing legacy debugMode omits rolloutPercent and rolloutSalt in the
mock configs; update the mockConfigService.get used in the spec (the test
invoking getPtcConfig and expecting PtcDebugMode.OPT_IN) to include
rolloutPercent and rolloutSalt keys (e.g., empty string or explicit defaults)
alongside 'ptc.mode', 'ptc.debug', 'ptc.userAllowlist', 'ptc.toolsetAllowlist',
and 'ptc.toolsetBlocklist' so the mock shape matches other tests and the config
parsing behavior remains consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@specs/change/20260309-ptc-rollout-percentage-priority/spec.md`:
- Line 95: Replace the PII in the sentence that reads "Live rollout test with
`anchori@163.com` (`u-kxuho6my1092vt8d1xshdrvx`): bucket = **2** → excluded at
`PTC_ROLLOUT_PERCENT=2`, included at `PTC_ROLLOUT_PERCENT=3`" by removing the
real email "anchori@163.com" and the user id "u-kxuho6my1092vt8d1xshdrvx" and
substituting anonymized placeholders (e.g., `user@example.com` or
`<REDACTED_EMAIL>` and `user-id-placeholder` or `<REDACTED_ID>`), keeping the
rest of the sentence and the `PTC_ROLLOUT_PERCENT` references intact.

In `@specs/current/ptc/PTC.md`:
- Around line 306-313: Remove the stray empty code fence after the environment
block in the "7. Gradual Rollout — Enable PTC for 10% of Users" section: locate
the orphaned opening triple backticks following the ```env block that contains
PTC_MODE and PTC_ROLLOUT_PERCENT and delete that extra ``` so the markdown has
only the intended fenced env block and no unmatched fence causing MD040.

---

Nitpick comments:
In `@apps/api/src/modules/config/app.config.ts`:
- Around line 264-265: Change parsing of the PTC_ROLLOUT_PERCENT env var to use
Number.parseInt to match other integer configs: replace the Number(...) usage
for rolloutPercent with Number.parseInt(process.env.PTC_ROLLOUT_PERCENT ??
'100', 10) (or equivalent) and include a fallback (|| 100) so invalid values
produce the default; ensure this aligns with the downstream getPtcConfig
behavior and references rolloutPercent and PTC_ROLLOUT_PERCENT in the change.

In `@apps/api/src/modules/tool/ptc/ptc-config.spec.ts`:
- Around line 263-301: Add tests to verify getPtcConfig clamps out-of-range
rolloutPercent values: mock the ConfigService (mockConfigService.get) to return
rolloutPercent -10 and assert getPtcConfig(...).rolloutPercent === 0, and
similarly return 150 and assert === 100; reference getPtcConfig and the
rolloutPercent property in your new test cases to ensure clamping behavior is
validated.
- Around line 75-89: The test for parsing legacy debugMode omits rolloutPercent
and rolloutSalt in the mock configs; update the mockConfigService.get used in
the spec (the test invoking getPtcConfig and expecting PtcDebugMode.OPT_IN) to
include rolloutPercent and rolloutSalt keys (e.g., empty string or explicit
defaults) alongside 'ptc.mode', 'ptc.debug', 'ptc.userAllowlist',
'ptc.toolsetAllowlist', and 'ptc.toolsetBlocklist' so the mock shape matches
other tests and the config parsing behavior remains consistent.

In `@apps/api/src/modules/tool/ptc/ptc-config.ts`:
- Around line 82-86: The computed rolloutPercent uses Math.min/Math.max on
configService.get<number>('ptc.rolloutPercent') but a NaN value will pass
through; update the logic where rolloutPercent is set to explicitly validate the
fetched value (from configService.get<number>('ptc.rolloutPercent')) with
Number.isFinite or Number.isNaN and fall back to the default 100 before
clamping, so rolloutPercent ends up as a finite number between 0 and 100; keep
the existing rolloutSalt logic unchanged.
- Around line 135-147: The JSDoc for isPtcEnabledForRollout should state that it
only computes deterministic rollout membership and does not perform other
eligibility checks; update the comment on isPtcEnabledForRollout to clarify it
assumes prior checks like isPtcEnabledForUser and isToolsetAllowed have been
performed and that it only uses computeRolloutBucket and
PtcConfig.rolloutSalt/rolloutPercent to decide rollout inclusion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a74ebe5-b9f1-4f88-9fa9-2a1f4bf7d52b

📥 Commits

Reviewing files that changed from the base of the PR and between b0dc755 and fcc177a.

📒 Files selected for processing (5)
  • apps/api/src/modules/config/app.config.ts
  • apps/api/src/modules/tool/ptc/ptc-config.spec.ts
  • apps/api/src/modules/tool/ptc/ptc-config.ts
  • specs/change/20260309-ptc-rollout-percentage-priority/spec.md
  • specs/current/ptc/PTC.md

Comment thread specs/change/20260309-ptc-rollout-percentage-priority/spec.md Outdated
Comment thread specs/current/ptc/PTC.md
- add PTC_ROLLOUT_PERCENT and PTC_ROLLOUT_SALT config values with safe defaults
- introduce deterministic user bucketing and apply rollout gating after mode and toolset eligibility checks
- expand ptc-config tests to cover rollout boundaries, determinism, and salt rebucketing behavior
- document rollout priority rules and add implementation notes in PTC specs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
specs/current/ptc/PTC.md (1)

306-313: ⚠️ Potential issue | 🟡 Minor

Remove the stray empty code fence.

There is still an orphaned triple-backtick after the env example, which breaks the markdown block structure.

Suggested fix
 #### 7. Gradual Rollout — Enable PTC for 10% of Users
 ```env
 PTC_MODE=on
 PTC_ROLLOUT_PERCENT=10

-```

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @specs/current/ptc/PTC.md around lines 306 - 313, Remove the stray closing
code fence after the env example under the "7. Gradual Rollout — Enable PTC for
10% of Users" section; specifically, keep a single fenced code block containing
the env lines (PTC_MODE=on and PTC_ROLLOUT_PERCENT=10) and delete the orphaned
triple-backtick that follows it so the Markdown block structure is correct.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (1)</summary><blockquote>

<details>
<summary>apps/api/src/modules/tool/ptc/ptc-config.spec.ts (1)</summary><blockquote>

`35-45`: **Add a regression case for invalid rollout values.**

The new coverage only exercises valid percentages. A `PTC_ROLLOUT_PERCENT='abc'` / `NaN` parse case would lock down the “safe default = 100” contract and catch the silent 0% behavior here.

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @apps/api/src/modules/tool/ptc/ptc-config.spec.ts around lines 35 - 45, Add a
regression test in ptc-config.spec.ts to cover invalid rollout values by calling
getPtcConfig with a mock ConfigService that returns an invalid string (e.g.
'abc' or NaN) for PTC_ROLLOUT_PERCENT and assert that the resulting
config.rolloutPercent falls back to the safe default of 100 (and rolloutSalt
remains 'ptc-rollout' if applicable); locate the test near the existing
getPtcConfig spec so it exercises the parsing logic in getPtcConfig and prevents
silent 0% behavior.


</details>

</blockquote></details>

</blockquote></details>

<details>
<summary>🤖 Prompt for all review comments with AI agents</summary>

Verify each finding against the current code and only fix it if needed.

Inline comments:
In @apps/api/src/modules/tool/ptc/ptc-config.ts:

  • Around line 128-132: The computeRolloutBucket function currently hashes uid +
    salt which is ambiguous (e.g., 'ab'+'c' == 'a'+'bc'); change the hash input to
    an unambiguous encoding (for example, join with a delimiter or length-prefix the
    parts) so uid and salt cannot collide, e.g., use a clear separator or
    Buffer.concat with a null/length byte between uid and salt before calling
    createHash('sha256'). Ensure you update computeRolloutBucket to use that new
    unambiguous input construction.
  • Around line 82-85: The rolloutPercent calculation should guard against NaN
    before clamping: when reading the value from
    configService.get('ptc.rolloutPercent') (used to compute
    rolloutPercent), detect NaN or non-finite values and replace them with the safe
    default (100) before applying Math.max/Math.min so isPtcEnabledForRollout()
    doesn't get NaN; update the expression that assigns rolloutPercent to first
    validate the fetched value (e.g., via Number.isFinite or isNaN) and fall back to
    100 if invalid, then clamp to 0–100.

In @specs/current/ptc/PTC.md:

  • Around line 240-246: Add a new paragraph documenting the PTC_ROLLOUT_SALT env
    var alongside PTC_ROLLOUT_PERCENT: specify the variable name PTC_ROLLOUT_SALT,
    its default value "ptc-rollout", and that it is concatenated with uid in the
    bucketing computation (SHA-256(uid + salt) % 100 < percent); also note
    recommended operator guidance (change the salt to reset/stagger bucket
    assignments across environments or releases, and keep it stable for a given
    rollout) so readers know when and why to rotate it.

Duplicate comments:
In @specs/current/ptc/PTC.md:

  • Around line 306-313: Remove the stray closing code fence after the env example
    under the "7. Gradual Rollout — Enable PTC for 10% of Users" section;
    specifically, keep a single fenced code block containing the env lines
    (PTC_MODE=on and PTC_ROLLOUT_PERCENT=10) and delete the orphaned triple-backtick
    that follows it so the Markdown block structure is correct.

Nitpick comments:
In @apps/api/src/modules/tool/ptc/ptc-config.spec.ts:

  • Around line 35-45: Add a regression test in ptc-config.spec.ts to cover
    invalid rollout values by calling getPtcConfig with a mock ConfigService that
    returns an invalid string (e.g. 'abc' or NaN) for PTC_ROLLOUT_PERCENT and assert
    that the resulting config.rolloutPercent falls back to the safe default of 100
    (and rolloutSalt remains 'ptc-rollout' if applicable); locate the test near the
    existing getPtcConfig spec so it exercises the parsing logic in getPtcConfig and
    prevents silent 0% behavior.

</details>

---

<details>
<summary>ℹ️ Review info</summary>

<details>
<summary>⚙️ Run configuration</summary>

**Configuration used**: defaults

**Review profile**: CHILL

**Plan**: Pro

**Run ID**: `d0220726-7063-411b-9920-af4200c211ff`

</details>

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between fcc177a4d12c7be585deaf88332c9d8a5a3f4613 and 6e4e5f1ffc645ef01c20e113751b2e2bffe471f8.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `apps/api/src/modules/config/app.config.ts`
* `apps/api/src/modules/tool/ptc/ptc-config.spec.ts`
* `apps/api/src/modules/tool/ptc/ptc-config.ts`
* `specs/change/20260309-ptc-rollout-percentage-priority/spec.md`
* `specs/current/ptc/PTC.md`

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (1)</summary>

* apps/api/src/modules/config/app.config.ts

</details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Comment on lines +82 to +85
const rolloutPercent = Math.min(
100,
Math.max(0, configService.get<number>('ptc.rolloutPercent') ?? 100),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard rolloutPercent against NaN before clamping.

app.config.ts uses Number(process.env.PTC_ROLLOUT_PERCENT ?? 100), so a misconfigured env like PTC_ROLLOUT_PERCENT=abc reaches this code as NaN. The current clamp preserves NaN, and isPtcEnabledForRollout() then disables PTC for everyone instead of falling back to the safe default.

Suggested fix
-  const rolloutPercent = Math.min(
-    100,
-    Math.max(0, configService.get<number>('ptc.rolloutPercent') ?? 100),
-  );
+  const rawRolloutPercent = Number(configService.get<number>('ptc.rolloutPercent'));
+  const rolloutPercent = Number.isFinite(rawRolloutPercent)
+    ? Math.min(100, Math.max(0, rawRolloutPercent))
+    : 100;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/tool/ptc/ptc-config.ts` around lines 82 - 85, The
rolloutPercent calculation should guard against NaN before clamping: when
reading the value from configService.get<number>('ptc.rolloutPercent') (used to
compute rolloutPercent), detect NaN or non-finite values and replace them with
the safe default (100) before applying Math.max/Math.min so
isPtcEnabledForRollout() doesn't get NaN; update the expression that assigns
rolloutPercent to first validate the fetched value (e.g., via Number.isFinite or
isNaN) and fall back to 100 if invalid, then clamp to 0–100.

Comment on lines +128 to +132
function computeRolloutBucket(uid: string, salt: string): number {
const hash = createHash('sha256')
.update(uid + salt)
.digest();
return hash.readUInt32BE(0) % 100;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Make the rollout hash input unambiguous.

Hashing uid + salt can collide for different pairs, e.g. ('ab', 'c') and ('a', 'bc'). That means changing PTC_ROLLOUT_SALT can fail to re-bucket some users in edge cases.

Suggested fix
 function computeRolloutBucket(uid: string, salt: string): number {
   const hash = createHash('sha256')
-    .update(uid + salt)
+    .update(JSON.stringify([uid, salt]))
     .digest();
   return hash.readUInt32BE(0) % 100;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function computeRolloutBucket(uid: string, salt: string): number {
const hash = createHash('sha256')
.update(uid + salt)
.digest();
return hash.readUInt32BE(0) % 100;
function computeRolloutBucket(uid: string, salt: string): number {
const hash = createHash('sha256')
.update(JSON.stringify([uid, salt]))
.digest();
return hash.readUInt32BE(0) % 100;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/modules/tool/ptc/ptc-config.ts` around lines 128 - 132, The
computeRolloutBucket function currently hashes uid + salt which is ambiguous
(e.g., 'ab'+'c' == 'a'+'bc'); change the hash input to an unambiguous encoding
(for example, join with a delimiter or length-prefix the parts) so uid and salt
cannot collide, e.g., use a clear separator or Buffer.concat with a null/length
byte between uid and salt before calling createHash('sha256'). Ensure you update
computeRolloutBucket to use that new unambiguous input construction.

Comment thread specs/current/ptc/PTC.md
Comment on lines +240 to +246
### Priority 4 — PTC_ROLLOUT_PERCENT
Integer 0–100, default `100`. Controls what percentage of otherwise-eligible users can use PTC via deterministic per-user bucketing.

- `100` (default): all eligible users included — no change to existing behaviour.
- `0`: kill switch — blocks all users without touching mode/toolset config.
- `1–99`: gradual rollout. Bucket is computed as `SHA-256(uid + salt) % 100 < percent`. Assignment is stable: the same user always gets the same result for a given salt.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Document PTC_ROLLOUT_SALT alongside the rollout percent.

This section explains that bucketing depends on a salt, but it never defines the env var itself, its default (ptc-rollout), or when operators should change it. That leaves the rollout docs incomplete for the new feature.

Suggested doc update
 ### Priority 4 — PTC_ROLLOUT_PERCENT
 Integer 0–100, default `100`. Controls what percentage of otherwise-eligible users can use PTC via deterministic per-user bucketing.
 
 - `100` (default): all eligible users included — no change to existing behaviour.
 - `0`: kill switch — blocks all users without touching mode/toolset config.
 - `1–99`: gradual rollout. Bucket is computed as `SHA-256(uid + salt) % 100 < percent`. Assignment is stable: the same user always gets the same result for a given salt.
+
+### Priority 4 — PTC_ROLLOUT_SALT
+String, default `ptc-rollout`. Seeds the deterministic rollout bucket used by `PTC_ROLLOUT_PERCENT`.
+
+- Keep it unchanged to preserve user assignment during a rollout.
+- Change it intentionally to re-bucket users for a new rollout wave.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/current/ptc/PTC.md` around lines 240 - 246, Add a new paragraph
documenting the PTC_ROLLOUT_SALT env var alongside PTC_ROLLOUT_PERCENT: specify
the variable name PTC_ROLLOUT_SALT, its default value "ptc-rollout", and that it
is concatenated with uid in the bucketing computation (SHA-256(uid + salt) % 100
< percent); also note recommended operator guidance (change the salt to
reset/stagger bucket assignments across environments or releases, and keep it
stable for a given rollout) so readers know when and why to rotate it.

@nettee nettee merged commit 76329f7 into main Mar 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants