Skip to content

guardrails: detect real clock reads in side-effects check; retire expired legacy-combo entries - #32

Open
div0rce wants to merge 1 commit into
mainfrom
guardrails/side-effects-time-precision
Open

div0rce wants to merge 1 commit into
mainfrom
guardrails/side-effects-time-precision

Conversation

@div0rce

@div0rce div0rce commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Summary

Resolves the expired legacy-combo side-effects allowlist that fails npm run check on every commit dated after 2026-06-01, by fixing what the guardrail measures rather than by extending the deadline.

What the rule protects. check:side-effects requires every module under lib/ (outside lib/adapters/runtime) that touches Prisma, the clock, env, fs, fetch, crypto, or randomness to be declared in scripts/side-effects.allowlist.json. Modules that mix Prisma access with a current-time read are tier legacy-combo and must carry an expiresBy date; check:side-effects:diff fails on expiry, growth, or any allowlist change that does not reduce legacy entries. The purpose is that persistence-touching legacy modules must not also read wall-clock time, so financial behaviour stays replayable with injected now.

What was wrong with the ten files. Nothing in behaviour. Each of the ten (lib/autopilot/{engineDecisionId,service}.ts, lib/bank/ingest.ts, lib/buckets/regimes.ts, lib/daily-state/runDailyForUser.ts, lib/dashboard.ts, lib/demo-seeder.ts, lib/income/monthly.ts, lib/unified-activity.ts, lib/vine/run-recommendation.ts) already receives now from its caller and only constructs Date objects from supplied values (new Date(ms), new Date(Date.UTC(...)), new Date(options.now.getTime() + ...)). None calls Date.now() or new Date(). The detector's pattern \bnew Date\s*\( could not distinguish a conversion from a clock read, so the files were classified as clock readers in 2025-12, given a March 2026 deadline, extended to June 2026, and expired. The same imprecision put four other modules (lib/buckets/periods.ts, lib/evaluator/regime-buckets.ts, lib/schemas/bank-ingest.ts, lib/bank/csv-dev-provider.ts) in the boundary-time tier for conversions only.

Changes.

  • check:side-effects: time now means Date.now() or new Date() with no arguments. Real clock reads are still caught (regression test). A root override env var enables fixture testing.
  • check:side-effects:diff: an entry may drop expiresBy only when it also leaves the legacy-combo tier (the tier is verified against detected effects by check:side-effects). Previously the rule fired even after the clock read was gone, which made remediation impossible without deleting the whole entry. Expiry against the HEAD commit date, growth rules, and the "must reduce legacy entries" rule are unchanged. Root override for fixture testing.
  • Allowlist: the ten entries become persistence-only (no expiry, same as the other 20 Prisma-using service modules); three conversion-only entries are deleted; csv-dev-provider keeps fs. Legacy entries 39 → 36, legacy-combo 10 → 0.
  • Regression test tests/node/guardrails/side-effects-time-detection.test.ts: clock reads flagged, conversions not, unlisted clock read fails; and in a throwaway git repo, a commit dated 2026-07-01 with an entry expiring 2026-06-01 fails while remediation on a 2026-09 commit passes (and a tier change alone does not satisfy the reduction rule).
  • docs/guardrails.md Guardrail 10 documents the tiers, the precise time definition, and that the epoch is the HEAD commit date (run the diff check after committing). docs/config-snapshot.md block regenerated.

Why not the alternative. Routing every new Date(value) through a helper would only relocate pure conversions to satisfy a regex; moving all Prisma access in these modules behind runtime adapters is the documented target for the whole service layer (66 lib/prisma importers) and is out of scope here.

No runtime code under lib/, app/, or components/ changed. No engine, accounting, session, or API semantics changed.

Testing

Node 24.15.0, npm ci, CHERRY_TMP_ROOT set, CHERRY_VINE_SIGNATURE_MODE=enforce.

Command Result
npm run check pass
npm test pass (all tests/** files)
npm run build pass
npm run check:side-effects:diff after committing (HEAD dated 2026-09-22) pass (exit 0)
tests/node/guardrails/side-effects-time-detection.test.ts ok

Risk

  • Domains: guardrail scripts, allowlist, docs, one test. Guardrail scope narrows only for argument-taking Date constructions; every Date.now() / new Date() in lib/ remains an error unless allowlisted, and the allowlist cannot grow.
  • check:side-effects:diff compares against HEAD~1, so on a squash merge to main it compares the previous main allowlist (10 combos) with this one (0): reduction satisfied.

Engine Impact

  • This PR does not modify engine behavior.

RetriggerConfidence Score: 4/5

The PR appears safe to merge, with two non-blocking hardening improvements recommended for guardrail root isolation and concurrent test reliability.

Findings

  1. P2 Root Override Redirects Checks ▶
  2. P2 Fixture Path Is Shared ▶

Summary

This PR narrows the side-effect guardrail’s clock detection to actual zero-argument Date reads, reclassifies conversion-only allowlist entries, and adds regression coverage for detection and expiry remediation.

  • Date.now() and zero-argument new Date() remain classified as clock reads.
  • Conversion-only entries are removed or moved out of legacy-combo.
  • The diff checker permits expiry removal when an entry leaves legacy-combo.
  • Documentation and the generated configuration snapshot are updated.
  • Two non-blocking hardening issues remain around the fixture root override and temporary test isolation.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Guardrail invocation] --> B{CHERRY_SIDE_EFFECTS_ROOT set?}
  B -->|No| C[Use current checkout]
  B -->|Yes| D[Use overridden root]
  C --> E[Scan lib source]
  D --> E
  E --> F[Detect Prisma and clock effects]
  F --> G[Validate allowlist effects and tier]
  C --> H[Read current and previous allowlists]
  D --> H
  H --> I[Check expiry, growth, and legacy reduction]
Loading

Reviews (1) · Last reviewed commit: "guardrails: detect real clock reads in s..."

…ired legacy-combo entries

- check:side-effects: 'time' now means Date.now() or new Date() with no
  arguments. new Date(value) is a pure conversion, not a side effect. Every
  allowlisted 'time' entry was a conversion; none of the ten legacy-combo
  modules reads the clock.
- check:side-effects:diff: an entry may drop expiresBy only when it also
  leaves the legacy-combo tier; expiry against HEAD date, growth rules and
  the legacy-reduction rule are unchanged.
- Allowlist: ten legacy-combo entries -> persistence-only; three
  conversion-only boundary-time entries removed (39 -> 36 legacy, 0 combos).
- Regression test with fixtures: clock reads flagged, conversions not, and
  expiry evaluated against commits dated after 2026-06-01 in a temp repo.
- docs/guardrails.md Guardrail 10; config snapshot regenerated.
@vercel

vercel Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cherry Ready Ready Preview Sep 22, 2026 11:10pm UTC

@coderabbitai

coderabbitai Bot commented Sep 22, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: a95bb3e0-d777-4786-97f0-ce18f2446cdf


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.

};

const ROOT = process.cwd();
const ROOT = process.env['CHERRY_SIDE_EFFECTS_ROOT'] ?? process.cwd();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Root Override Redirects Checks

If CHERRY_SIDE_EFFECTS_ROOT is inherited from a previous local task or runner configuration, normal guardrail invocations use it without validation. This redirects both the lib scan and allowlist lookup; the diff checker also reads Git history from that root. As a result, npm run check can report success without examining the current checkout. Please scope or validate this fixture override so normal guardrail entry points cannot silently use it.

Comment on lines +33 to +44
const unlisted = path.join(repoRoot, 'tests', 'fixtures', 'guardrails', 'repo', 'side-effects-unlisted');
fs.rmSync(unlisted, { recursive: true, force: true });
fs.mkdirSync(path.join(unlisted, 'lib'), { recursive: true });
fs.mkdirSync(path.join(unlisted, 'scripts'), { recursive: true });
fs.copyFileSync(path.join(fixtureRoot, 'lib', 'clock-read.ts'), path.join(unlisted, 'lib', 'clock-read.ts'));
fs.writeFileSync(path.join(unlisted, 'scripts', 'side-effects.allowlist.json'), '{}\n');
try {
const missing = runGuardrail('scripts/check-side-effects.mts', unlisted);
assert.notEqual(missing.status, 0, 'expected an unlisted clock read to fail');
assert.equal(missing.output.includes('lib/clock-read.ts: time'), true, missing.output);
} finally {
fs.rmSync(unlisted, { recursive: true, force: true });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Fixture Path Is Shared

This test creates and recursively deletes a fixed directory inside the checkout. Two overlapping test runs can remove or partly rewrite each other's fixture, causing intermittent copy or read failures and unreliable guardrail results. Setup failures before the try block can also leave an untracked directory behind. Use a unique temporary directory, as the Git fixture later in this test already does.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f63362f9ac

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

const ROOT = process.cwd();
const ROOT = process.env['CHERRY_SIDE_EFFECTS_ROOT'] ?? process.cwd();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the guardrail root fixed in registered runs

Reading CHERRY_SIDE_EFFECTS_ROOT in the production guardrail exposes a complete bypass: setting it to a directory containing an empty lib/ and {} allowlist makes npm run check:side-effects report success without inspecting this repository. The same override also redirects the diff guardrail. Keep registered execution rooted at process.cwd() and expose fixture-root injection through a test-only helper or otherwise reject this variable in normal runs.

AGENTS.md reference: AGENTS.md:L66-L70

Useful? React with 👍 / 👎.

Comment thread docs/guardrails.md
Comment on lines +310 to +314
- `check:side-effects` scans `lib/**` (excluding `lib/adapters/runtime/**`) for effects: Prisma access, clock reads (`Date.now()` or `new Date()` with no arguments; `new Date(value)` is a pure conversion, not an effect), `process.env`, fs, fetch, crypto, randomness, Next request helpers. Every hit must be declared in `scripts/side-effects.allowlist.json` with `source: legacy`.
- Tiers: `persistence-only` (Prisma, no clock), `boundary-time` (clock or other effects, no Prisma), `legacy-combo` (Prisma and a clock read in the same module).
- `legacy-combo` entries require `expiresBy: YYYY-MM-DD`. `check:side-effects:diff` fails when an entry is expired, when entries or effects grow, and when an allowlist change does not reduce the number of legacy entries.
- The expiry epoch is the HEAD commit date (guardrails may not read the wall clock), so run `npm run check:side-effects:diff` after committing, not only on the working tree.
- Guardrail checks: `check:side-effects`, `check:side-effects:diff`. Tests: `tests/node/guardrails/side-effects-time-detection.test.ts`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Remove implementation paths from the guardrail documentation

These additions name scripts/side-effects.allowlist.json and tests/node/guardrails/side-effects-time-detection.test.ts, although docs/doctrine.md §5.1 requires documentation to refer only to guardrail names rather than implementation paths. Describe the policy through check:side-effects and check:side-effects:diff without embedding paths that can drift.

AGENTS.md reference: AGENTS.md:L6-L7

Useful? React with 👍 / 👎.

@@ -0,0 +1,108 @@
import * as assert from 'node:assert/strict';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Split the tests and documentation into separate commits

This commit combines scripts/**, tests/**, and docs/**, contrary to the active docs/doctrine.md §8 requirement that guardrail, test, and documentation concerns be isolated into their respective commit categories. Split these companion changes into separately categorized commits before merging.

AGENTS.md reference: AGENTS.md:L6-L7

Useful? React with 👍 / 👎.

Comment on lines +20 to +23
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, CHERRY_SIDE_EFFECTS_ROOT: root },
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear epoch overrides in the HEAD-date regression test

The spawned guardrail inherits SOURCE_DATE_EPOCH and CHERRY_GUARDRAIL_EPOCH, but resolveEpochMs() gives those variables precedence over the temporary repository's HEAD date. In reproducible-build environments that set SOURCE_DATE_EPOCH, the nominal 2026-05-31 pre-expiry assertion can therefore fail; clear both overrides from the child environment, or explicitly set the intended epoch for each case.

Useful? React with 👍 / 👎.

Comment on lines +63 to +65
// A clock read is `Date.now()` or `new Date()` with no arguments. `new Date(value)` is a pure
// conversion of a supplied timestamp and is not a side effect.
time: /\bnew Date\s*\(\s*\)|\bDate\.now\s*\(/,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Continue detecting empty-spread Date constructors

The narrowed expression misses real zero-argument clock reads written with a spread, such as new Date(...[]) or a typed const args: [] = []; new Date(...args). The previous expression detected these, but they now pass without a time effect even though they read the wall clock at runtime; distinguish conversions only when an actual argument is guaranteed rather than treating every syntactically nonempty argument list as pure.

Useful? React with 👍 / 👎.

Comment on lines +18 to +23
function runGuardrail(script: string, root: string): { status: number | null; output: string } {
const result = spawnSync('npm', ['run', 'ts:esm', '--', script], {
cwd: repoRoot,
encoding: 'utf8',
env: { ...process.env, CHERRY_SIDE_EFFECTS_ROOT: root },
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Exercise guardrails through their registered commands

The test invokes each guardrail by passing its script path directly to ts:esm, bypassing the registered check:<name> command and scripts/guardrails/run.mts. Consequently the regression test can stay green if registry routing or runner behavior breaks, and it violates the repository rule that guardrails are unaddressable by path; invoke npm run check:side-effects and npm run check:side-effects:diff instead.

AGENTS.md reference: AGENTS.md:L66-L70

Useful? React with 👍 / 👎.

Comment on lines +33 to +38
const unlisted = path.join(repoRoot, 'tests', 'fixtures', 'guardrails', 'repo', 'side-effects-unlisted');
fs.rmSync(unlisted, { recursive: true, force: true });
fs.mkdirSync(path.join(unlisted, 'lib'), { recursive: true });
fs.mkdirSync(path.join(unlisted, 'scripts'), { recursive: true });
fs.copyFileSync(path.join(fixtureRoot, 'lib', 'clock-read.ts'), path.join(unlisted, 'lib', 'clock-read.ts'));
fs.writeFileSync(path.join(unlisted, 'scripts', 'side-effects.allowlist.json'), '{}\n');

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Allocate the unlisted fixture outside the checkout

This scratch fixture is created at a fixed path inside the source tree after first recursively deleting that path. Concurrent test runs can delete or overwrite one another's fixture, and an interruption before the finally block leaves the worktree dirty; allocate it with mkdtempSync under CHERRY_TMP_ROOT as the later temporary Git repository already does.

Useful? React with 👍 / 👎.

Comment on lines +1 to +5
export function nowMs(): number {
return Date.now();
}
export function today(): Date {
return new Date();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Test each clock-read syntax independently

Both Date.now() and new Date() occur in the same allowlisted file, while the assertion only checks that the file has a single time effect. If either half of the new alternation is accidentally removed later, the other call still produces the identical successful result, so this regression test does not actually protect both promised syntaxes; place them in separate fixtures or run independent negative cases.

Useful? React with 👍 / 👎.

This branch was successfully deployed

1 active deployment
Preview — f63362f9 Deployed Sep 22, 2026 by vercel[bot]
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.

1 participant