You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make timing.client_sent_msg_ts nullable and persist NULL — instead of the Unix epoch — when a chat request supplies no client_sent_msg_ts, so a row never records 1970-01-01T00:00:00Z as if it were a measured send time.
Context you need
What is true today. PR #185 (merged as part of issue #175) made client_sent_msg_ts and client_timeout optional on both chat endpoints. Before it, a request omitting either was refused with HTTP 408 inside _prepare_chat_context and never reached persistence. It now completes, so for the first time such a request writes a timing row.
_parse_chat_request coerces the absent field to 0:
src/interfaces/chat_app/app.py:4680
client_sent_msg_ts = client_sent_msg_ts / 1000 if client_sent_msg_ts else 0
Both completion paths then convert that 0 and persist it:
src/interfaces/chat_app/app.py:2550 — streaming (ChatWrapper.stream), written via insert_timing a few lines later, guarded by if message_ids:
src/interfaces/chat_app/app.py:4767 — non-streaming (get_chat_response), written via self.chat.insert_timing
Both do datetime.fromtimestamp(client_sent_msg_ts, tz=timezone.utc), which for 0 is 1970-01-01T00:00:00+00:00. Line numbers are as of 0365a07de66cf14706af79b4606097087b0b749a (the head of fix/issue-175-optional-client-timeout); re-derive them with grep rather than trusting them, because app.py shifts on nearly every merge.
Why the epoch is there and not NULL. The column forbids it:
src/cli/templates/init.sql:476
client_sent_msg_ts TIMESTAMPTZ NOT NULL,
insert_timing (src/interfaces/chat_app/app.py:1500) builds a positional tuple and executes SQL_INSERT_TIMING (src/utils/sql.py:70-85), which names all twelve columns. Passing None today raises NotNullViolation; in the non-streaming path that call sits outside the try, so the request would return 500.
Why it was deferred rather than fixed in #185. Shipping the code change without a guaranteed schema migration turns a data-quality defect into a hard 500 on any deployment whose schema predates the migration — and issue #180 records that migrations are not applied to existing deployments. That is this issue's blocker: do not start until #180 is closed, or until you have confirmed by inspection that the migration mechanism reaches existing deployments.
The current behaviour is specified, not accidental: the requirement "A timing row records an absent client send time as a specified sentinel" in openspec/specs/chat-api-request-contract/spec.md (added by fix(chat): treat absent client timeout as no deadline, not expired #185's change fix-issue-175-optional-client-timeout). This issue retires that requirement — expect to write a ## MODIFIED Requirements delta, not a new one bolted alongside.
Tests that pin the epoch and will need to flip: tests/unit/test_chat_timing_persistence.py, added in 5872eb1b. Three tests; the second (test_the_absent_send_time_is_the_epoch_and_not_a_server_side_substitute) is the one that must go red first.
Documentation of the sentinel to remove: the client_sent_msg_ts row of the request-body table in docs/docs/api_reference.md, and the [sentinel] link definition.
What does not need changing. No shipped consumer reads this column. src/cli/templates/grafana/archi-default-dashboard.json keys off server_received_msg_ts and msg_duration only — verify with grep -c client_sent_msg_ts on that file (expect 0). msg_duration is computed at app.py:1519 from server_response_msg_ts - server_received_msg_ts and does not involve this column. src/interfaces/chat_app/openai_compat.py:274 synthesises now.timestamp(), so that path never supplies an absent value.
Do not drop the timing row when the send time is absent. Its other ten milestones are real measurements and are what the dashboards plot.
Constraints
Branch from origin/dev. Open the PR with gh pr create --repo fasrc/archi --base dev. Notupstream/dev (archi-physics/archi) — that fork is ~100 commits behind and is not the working trunk. Never commit to dev directly.
TDD. Flip the failing test in tests/unit/test_chat_timing_persistence.py first, watch it fail for the right reason, then change the code.
Gate before every commit:bash scripts/gate.sh, run bare — no pipe, no redirect. It must pass format, lint, tests, and ≥80% diff coverage on changed lines. Never --no-verify.
No Co-Authored-By or other AI-attribution trailers. Short lowercase commit subjects.
Use OpenSpec: /opsx:propose → /opsx:apply → /opsx:archive.
Never merge. A human merges.
Two changes must land together or the deployment breaks: the migration must be applied before the code that writes NULL runs. State the ordering explicitly in the PR body and in the change's tasks.md. If they cannot be guaranteed to land together, make the writer tolerate both schemas (attempt NULL, fall back to the epoch on NotNullViolation) and say so — a 500 is not an acceptable outcome for a legal request.
Schema. Add src/cli/templates/migrations/make_timing_client_sent_nullable.sql (ALTER TABLE timing ALTER COLUMN client_sent_msg_ts DROP NOT NULL;, idempotent, with the same header-comment style as the two existing migrations), and drop NOT NULL from that column in src/cli/templates/init.sql so new deployments match.
Tests first. Change tests/unit/test_chat_timing_persistence.py to assert None, watch it fail, and add a case proving a supplied value still round-trips (that discriminator already exists — keep it).
Code. At app.py:2550 and app.py:4767, persist None when client_sent_msg_ts is falsey and the converted datetime otherwise. Prefer one small shared helper over two copies of the conditional; both sites are otherwise identical.
Spec. MODIFY the sentinel requirement to require NULL, and state that a timing row is still written.
Docs. Remove the sentinel sentence and the [sentinel] link definition from docs/docs/api_reference.md; say the column is NULL when the client declares no send time.
Separate PR for anything else. If you find yourself repairing api_reference.md line anchors, that is issue #190 — do not fold it in.
Commands
# ground truth for the line numbers above (they drift)
grep -n 'client_sent_msg_ts' src/interfaces/chat_app/app.py
grep -n 'client_sent_msg_ts' src/cli/templates/init.sql src/utils/sql.py
# confirm no dashboard reads the column (expect 0)
grep -c client_sent_msg_ts src/cli/templates/grafana/archi-default-dashboard.json
# red first, then green
python -m pytest tests/unit/test_chat_timing_persistence.py -q
# the gate — bare, no pipe, no redirect
bash scripts/gate.sh
openspec validate <change-name> --strict
Acceptance criteria
grep -n 'client_sent_msg_ts' src/cli/templates/init.sql shows the column withoutNOT NULL.
src/cli/templates/migrations/make_timing_client_sent_nullable.sql exists, is idempotent (running it twice is not an error), and drops the constraint.
python -m pytest tests/unit/test_chat_timing_persistence.py -q passes with the tests asserting None, and at least one test still proves a supplied timestamp is persisted unchanged.
grep -rn 'fromtimestamp' src/interfaces/chat_app/app.py shows no site that can be reached with a falsey client_sent_msg_ts.
bash scripts/gate.sh passes with ≥80% diff coverage on the changed lines.
openspec validate <change-name> --strict passes, and the change contains a ## MODIFIED Requirements delta retiring the epoch sentinel rather than a second contradictory requirement.
The PR body states the migration-before-code ordering, or documents the dual-schema fallback and why it was needed.
Start here
Run gh issue view 180 --repo fasrc/archi --json state,title first. If it is still open, do not proceed — comment on this issue saying #180 blocks it and stop. If it is closed, read src/cli/templates/migrations/add_documents_last_modified.sql and whatever now applies it, then start at step 1.
Deferred from the round-3 review of PR #185 (fix SHA 5872eb1b), where the epoch was specified as an interim sentinel rather than left accidental. Blocked on #180. Related: #190 (self-verifying api_reference.md anchors).
Objective
Make
timing.client_sent_msg_tsnullable and persistNULL— instead of the Unix epoch — when a chat request supplies noclient_sent_msg_ts, so a row never records1970-01-01T00:00:00Zas if it were a measured send time.Context you need
What is true today. PR #185 (merged as part of issue #175) made
client_sent_msg_tsandclient_timeoutoptional on both chat endpoints. Before it, a request omitting either was refused with HTTP 408 inside_prepare_chat_contextand never reached persistence. It now completes, so for the first time such a request writes atimingrow._parse_chat_requestcoerces the absent field to0:Both completion paths then convert that
0and persist it:src/interfaces/chat_app/app.py:2550— streaming (ChatWrapper.stream), written viainsert_timinga few lines later, guarded byif message_ids:src/interfaces/chat_app/app.py:4767— non-streaming (get_chat_response), written viaself.chat.insert_timingBoth do
datetime.fromtimestamp(client_sent_msg_ts, tz=timezone.utc), which for0is1970-01-01T00:00:00+00:00. Line numbers are as of0365a07de66cf14706af79b4606097087b0b749a(the head offix/issue-175-optional-client-timeout); re-derive them with grep rather than trusting them, becauseapp.pyshifts on nearly every merge.Why the epoch is there and not
NULL. The column forbids it:insert_timing(src/interfaces/chat_app/app.py:1500) builds a positional tuple and executesSQL_INSERT_TIMING(src/utils/sql.py:70-85), which names all twelve columns. PassingNonetoday raisesNotNullViolation; in the non-streaming path that call sits outside thetry, so the request would return 500.Why it was deferred rather than fixed in #185. Shipping the code change without a guaranteed schema migration turns a data-quality defect into a hard 500 on any deployment whose schema predates the migration — and issue #180 records that migrations are not applied to existing deployments. That is this issue's blocker: do not start until #180 is closed, or until you have confirmed by inspection that the migration mechanism reaches existing deployments.
What already exists to build on.
src/cli/templates/migrations/add_documents_last_modified.sqlandsrc/cli/templates/migrations/rename_mid_to_message_id.sql. Read both before writing a third —add_documents_last_modified.sqlis the one last_modified migration is never applied to existing deployments (ingest then silently no-ops) #180 is about.openspec/specs/chat-api-request-contract/spec.md(added by fix(chat): treat absent client timeout as no deadline, not expired #185's changefix-issue-175-optional-client-timeout). This issue retires that requirement — expect to write a## MODIFIED Requirementsdelta, not a new one bolted alongside.tests/unit/test_chat_timing_persistence.py, added in5872eb1b. Three tests; the second (test_the_absent_send_time_is_the_epoch_and_not_a_server_side_substitute) is the one that must go red first.client_sent_msg_tsrow of the request-body table indocs/docs/api_reference.md, and the[sentinel]link definition.What does not need changing. No shipped consumer reads this column.
src/cli/templates/grafana/archi-default-dashboard.jsonkeys offserver_received_msg_tsandmsg_durationonly — verify withgrep -c client_sent_msg_tson that file (expect0).msg_durationis computed atapp.py:1519fromserver_response_msg_ts - server_received_msg_tsand does not involve this column.src/interfaces/chat_app/openai_compat.py:274synthesisesnow.timestamp(), so that path never supplies an absent value.Do not drop the timing row when the send time is absent. Its other ten milestones are real measurements and are what the dashboards plot.
Constraints
origin/dev. Open the PR withgh pr create --repo fasrc/archi --base dev. Notupstream/dev(archi-physics/archi) — that fork is ~100 commits behind and is not the working trunk. Never commit todevdirectly.tests/unit/test_chat_timing_persistence.pyfirst, watch it fail for the right reason, then change the code.bash scripts/gate.sh, run bare — no pipe, no redirect. It must pass format, lint, tests, and ≥80% diff coverage on changed lines. Never--no-verify.Co-Authored-Byor other AI-attribution trailers. Short lowercase commit subjects./opsx:propose→/opsx:apply→/opsx:archive.NULLruns. State the ordering explicitly in the PR body and in the change'stasks.md. If they cannot be guaranteed to land together, make the writer tolerate both schemas (attemptNULL, fall back to the epoch onNotNullViolation) and say so — a 500 is not an acceptable outcome for a legal request.Plan
src/cli/templates/migrations/is actually executed against an existing deployment. If it is not, stop and report — the rest of this plan is unsafe.src/cli/templates/migrations/make_timing_client_sent_nullable.sql(ALTER TABLE timing ALTER COLUMN client_sent_msg_ts DROP NOT NULL;, idempotent, with the same header-comment style as the two existing migrations), and dropNOT NULLfrom that column insrc/cli/templates/init.sqlso new deployments match.tests/unit/test_chat_timing_persistence.pyto assertNone, watch it fail, and add a case proving a supplied value still round-trips (that discriminator already exists — keep it).app.py:2550andapp.py:4767, persistNonewhenclient_sent_msg_tsis falsey and the converted datetime otherwise. Prefer one small shared helper over two copies of the conditional; both sites are otherwise identical.NULL, and state that atimingrow is still written.[sentinel]link definition fromdocs/docs/api_reference.md; say the column isNULLwhen the client declares no send time.api_reference.mdline anchors, that is issue #190 — do not fold it in.Commands
Acceptance criteria
grep -n 'client_sent_msg_ts' src/cli/templates/init.sqlshows the column withoutNOT NULL.src/cli/templates/migrations/make_timing_client_sent_nullable.sqlexists, is idempotent (running it twice is not an error), and drops the constraint.python -m pytest tests/unit/test_chat_timing_persistence.py -qpasses with the tests assertingNone, and at least one test still proves a supplied timestamp is persisted unchanged.grep -rn 'fromtimestamp' src/interfaces/chat_app/app.pyshows no site that can be reached with a falseyclient_sent_msg_ts.grep -c sentinel docs/docs/api_reference.mdreturns0.bash scripts/gate.shpasses with ≥80% diff coverage on the changed lines.openspec validate <change-name> --strictpasses, and the change contains a## MODIFIED Requirementsdelta retiring the epoch sentinel rather than a second contradictory requirement.Start here
Run
gh issue view 180 --repo fasrc/archi --json state,titlefirst. If it is still open, do not proceed — comment on this issue saying #180 blocks it and stop. If it is closed, readsrc/cli/templates/migrations/add_documents_last_modified.sqland whatever now applies it, then start at step 1.Deferred from the round-3 review of PR #185 (fix SHA
5872eb1b), where the epoch was specified as an interim sentinel rather than left accidental. Blocked on #180. Related: #190 (self-verifyingapi_reference.mdanchors).