Skip to content

Reject booleans and non-finite numbers in the chat timing fields instead of coercing them #195

Description

@swinney

Objective

Reject a JSON boolean supplied for client_sent_msg_ts or client_timeout with HTTP 400, instead of silently treating true as 0.001 and false as "field omitted".

Context you need

src/interfaces/chat_app/request_validation.py normalizes both timing fields through _milliseconds_to_seconds, which guards the division against OverflowError and TypeError. bool is a subclass of int in Python, so a boolean satisfies neither guard and sails through:

$ python -c "from src.interfaces.chat_app.request_validation import parse_client_timeout as p; print(p(True), p(False))"
0.001 0

Measured on dev at dd3fb6b4 — all four combinations are accepted, none raises:

payload result consequence
"client_timeout": true 0.001 a 1-millisecond deadline. Any request that also supplies client_sent_msg_ts is past it on arrival, so _prepare_chat_context returns 408; on the streaming route the in-stream check ends the stream with an in-band 408 almost immediately.
"client_timeout": false 0 silently identical to omitting the field
"client_sent_msg_ts": true 0.001 persisted as 1970-01-01T00:00:00.001Z — indistinguishable from a real measurement at a glance, and not the documented 1970-01-01T00:00:00Z absent-value sentinel
"client_sent_msg_ts": false 0 silently identical to omitting the field

true is the harmful one: a client that serializes a flag into the wrong field gets 408 on every request with no indication that the field was the problem.

Why this is not already covered. The guards catch what cannot be divided (OverflowError, TypeError); a boolean divides fine. The falsey guard if not value: return 0 runs first, so False never reaches the division at all — a type check placed after it would miss false entirely. Put the boolean check before the falsey guard.

How it was found. Adversarial review of PR for #194 (docs), which had claimed "anything not numeric is rejected". That claim was corrected to name only strings, arrays and objects — the types actually rejected — so the documentation is currently accurate about a contract that has this hole. Closing the hole lets the docs say "a boolean" too.

Not a regression. This behaviour predates #175/#185; those changes added the guards that made every other non-numeric type a 400 and left this one gap.

Constraints

  • Branch from origin/dev. PR with gh pr create --repo fasrc/archi --base dev. Not upstream/dev. Never commit to dev directly.
  • TDD: add the failing endpoint tests first, watch them fail with a 200/408 rather than 400, then change request_validation.py.
  • bash scripts/gate.sh must pass, run bare (no pipe, no redirect), with ≥80% diff coverage on changed lines. Never --no-verify.
  • No Co-Authored-By or AI-attribution trailers. Short lowercase commit subject.
  • Update docs/docs/api_reference.md in the same change — both timing-field rows list the rejected types, and a boolean belongs in that list once it is rejected. Do not renumber any [name]: …/app.py#Lnnn anchor (that is Make the api_reference.md app.py line anchors self-verifying #190).
  • Uses OpenSpec: this changes a documented request contract, so add a delta to chat-api-request-contract rather than editing a main spec in place.
  • Never merge. A human merges.

Plan

  1. Add failing cases to tests/unit/test_chat_timing_field_validation.py::TestNormalizationItselfCannotRaise — its CASES list and _post harness already drive both routes with a real JSON body. Add (field, True) and (field, False) for both fields; all four must fail before the fix.
  2. In _milliseconds_to_seconds, reject isinstance(value, bool) before the if not value guard, raising InvalidClientTiming with a message naming the field, consistent with the existing wording.
  3. Confirm no legitimate caller sends booleans: grep -rn "client_timeout\|client_sent_msg_ts" src/interfaces/chat_app/static/ src/interfaces/chat_app/openai_compat.py — the JS client sends Date.now() and a number, and openai_compat.py synthesizes now.timestamp().
  4. Add the OpenSpec delta and add "a boolean" to the rejected-type lists in both api_reference.md rows.

Commands

# reproduce (expect: 0.001 0)
python -c "from src.interfaces.chat_app.request_validation import parse_client_timeout as p; print(p(True), p(False))"

# red first, then green
python -m pytest tests/unit/test_chat_timing_field_validation.py -q

bash scripts/gate.sh
openspec validate <change-name> --strict

Acceptance criteria

  • python -c "from src.interfaces.chat_app.request_validation import parse_client_timeout as p; p(True)" exits non-zero with InvalidClientTiming; same for p(False), parse_client_sent_msg_ts(True), parse_client_sent_msg_ts(False).
  • Both chat routes return 400 for a boolean in either timing field, and the error names the field.
  • A real number is still accepted: parse_client_timeout(600000) == 600.0 and parse_client_sent_msg_ts(1700000000000) == 1700000000.0.
  • An omitted field still yields 0parse_client_timeout(None) == 0. Rejecting false must not break omission.
  • grep -c boolean docs/docs/api_reference.md ≥ 1.
  • git diff origin/dev -- docs/docs/api_reference.md | grep -c '#L[0-9]' returns 0.
  • bash scripts/gate.sh passes with ≥80% diff coverage; openspec validate <change> --strict passes.

Start here

Run the reproduce command above and confirm it prints 0.001 0. Then read _milliseconds_to_seconds and note that the falsey guard precedes the division — that ordering is why the check has to go first.


Found by adversarial review of the #194 docs PR. Related: #190 (anchor drift), #193 (nullable timing column), #194 (the docs rows this would let us strengthen).

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Priority: this cycleai-wipClaimed by an automated run; in flightauto-okOpt-in: the nightly run may pick this issue up and open a PRbugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions