fix(core): throw RangeError on unparseable evaluation instant (#1166) - #1176
Conversation
…i#1166) evaluationInstant(now) in packages/core/src/validity.ts returned Date.now() when closesAt(now) was null, causing callers that pass an unparseable date/instant to be silently answered as of the present moment instead. getCandidatePairsDetailed exposes options.now publicly so callers can evaluate temporal states at a specific instant; a typo produced a plausible-looking answer for the current time with nothing reported. Unlike stored bounds (where an unparseable value is treated as absent to avoid hiding valid content over data errors), a caller-supplied evaluation instant is an explicit argument. An unparseable now string now throws a RangeError so bad inputs fail fast rather than silently corrupting temporal evaluations. - Update evaluationInstant docstring and add RangeError throw on unparseable now. - Add unit test suite in packages/core/test/validity-instants.test.ts asserting empty default, date-only end-of-day UTC, explicit instant, naive UTC-pinning, and RangeError throw. - Add regression test in packages/core/test/tensions.test.ts verifying getCandidatePairs fails fast when options.now is unparseable. Closes plur-ai#1166 Signed-off-by: amasen02 <amasen02@users.noreply.github.com>
miles-on-nightshift
left a comment
There was a problem hiding this comment.
The RangeError is correct here — a caller-supplied now is an explicit argument, not a stored field, so silent fallback to the current time is worse than a loud failure. The asymmetry with isExpired/isNotYetValid (which treat unparseable bounds as absent) is real but intentional and the docstring now explains why. Test covers the new throw path and the unchanged no-now path. CI passing. @plur9 ready for merge.
|
Gentle ping on throwing RangeError for unparseable evaluation instants (122 tests green). Ready to adjust the error surface per feedback. |
crtahlin
left a comment
There was a problem hiding this comment.
Approved. Two documentation gaps are worth closing, neither blocking — filed as follow-ups.
The fix is correct and minimal. evaluationInstant now throws a RangeError on a caller-supplied now it cannot parse, instead of silently answering as of the present moment, and the docstring explains why this diverges from isExpired and isNotYetValid rather than merely stating that it does — a stored bound is schema-validated data where hiding content is the worse failure, a caller-supplied argument is an explicit instruction. That reasoning is the part a future reader would otherwise have to reconstruct.
The regression is asserted twice, once on the helper and once through the public getCandidatePairs surface a real caller uses. Both bite: reverting the throw to return ms === null ? Date.now() : ms turns exactly those two assertions red and leaves everything else green. The control tests for the forms that must keep working are there too — date-only resolving to end-of-day UTC, an explicit Z, a +02:00 offset, and a zone-less timestamp pinned to UTC — so the end-of-day semantics that preserve the pre-#1150 answer are protected rather than assumed.
RangeError matches local practice: packages/core/src/expiry.ts:127 throws the same type for an out-of-order valid_from/valid_until, also a well-typed string carrying an unacceptable value.
Notes
packages/core/src/tensions.ts:110—TemporalGateOptions.nowis the doc a caller actually reads, and it still describes only the old contract: an "ISO date treated as 'today'" that "defaults to the current date". Nothing there says a malformed value is now fatal. #1166 asked for the behaviour to be documented, and it is — thoroughly, on the internal helper, but not on the option the caller sets. Filed as #1179.packages/core/src/validity.ts:177,180— the@throwsclause says the function throws whennowis "provided but cannot be parsed", but the empty string is provided, cannot be parsed, and does not throw, because line 180 uses a falsy check. The new test pins that, so it is deliberate — butnow: ''from a blank config field is the same silent-wrong-answer this change exists to stop. Filed as #1180, together with the accepted-forms list being narrower than the behaviour:pinToUtcalso takes a zone-less timestamp, which is not valid RFC 3339.packages/core/test/validity-instants.test.ts:231— the empty-string assertion checks only the lower bound, unlike the omitted-argument case two lines above. A value far in the future would still pass.
Blast radius, since a new throw deserves the check
Effectively nil on shipped surfaces. Neither the CLI (packages/cli/src/commands/tensions.ts:173) nor the MCP tool (packages/mcp/src/tools.ts:3925) ever sets now; there is no --now flag and no now field in the tool schema; and evaluationInstant is not re-exported from packages/core/src/index.ts. The throw is reachable only from library embedders and tests, so this does not turn a quiet wrong answer into a user-facing crash anywhere today.
Tests: PASS — full packages/core suite, 3679 passed. One unrelated failure, test/injection-dedup.test.ts ("four concurrent processes write ONE event"), reproduces identically on the base commit d005139e and is a concurrency-sensitive test flaking under local parallel load; CI is green on that same commit. The targeted files are 122/122.
Verified: the throw was reverted to the old fallback to confirm both new tests fail; every accepted now form was exercised; every caller of evaluationInstant was traced to confirm reachability.
|
Gentle nudge — this one is approved by both @miles-on-nightshift and @crtahlin with all checks green. Ready to merge whenever convenient; happy to make any final adjustments if needed. Thanks! |
GitHub appends the PR number to a squash-merge subject, so this PR lands on main as "... (#1190) (#1191)" — the repo's own history shows the shape, e.g. "fix(core): throw RangeError on unparseable evaluation instant (#1166) (#1176)". The manifest gate parses EVERY number in a squash subject, not just the last, so #1191 counts as a shipped user-facing PR the moment this merges and must be declared or Step 3.6 aborts the release. Declaring it here, before the merge, because the CHANGELOG it would need to appear in is inside this PR. Simulated post-merge: 61 PR numbers shipped, 61 declared. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S4or5TZEfYoreFm7GLJwRJ
Summary
Resolves #1166.
In
packages/core/src/validity.ts,evaluationInstant(now)previously fell back toDate.now()whenclosesAt(now)returnednull:As noted in #1166, while stored engram bounds (
isExpired,isNotYetValid) treat unparseable timestamps as absent to avoid hiding valid content over data errors, a caller-provided evaluation instant (nowinoptions.nowacrossgetCandidatePairsDetailed/scanForTensions) is an explicit argument. Falling back to the current time silently answered queries as of the present moment with no notification that the instant parameter was unparseable.Proposed Changes
packages/core/src/validity.ts:RangeErrorcontract for caller-supplied evaluation instants.RangeError('Unparseable evaluation instant: ... Expected YYYY-MM-DD or an RFC 3339 timestamp.')whennowis non-empty but cannot be parsed as a date or instant.packages/core/test/validity-instants.test.ts:evaluationInstant (#1166)unit test suite asserting:nowreturns current timestamp.YYYY-MM-DDdate resolves to end-of-day UTC (23:59:59.999Z).RangeErrorwith descriptive message.packages/core/test/tensions.test.ts:getCandidatePairsfails fast withRangeErrorwhenoptions.nowis unparseable.Verification
npm test -- packages/core/test/validity-instants.test.ts packages/core/test/tensions.test.ts: 122/122 passed.packages/corebuilds cleanly vianpm run build(tsup).