Skip to content

Commit fdbefb2

Browse files
fix(state): never emit created_at on the wire (#146)
#144 removed the ts -> created_at carry but left created_at in the allowlist the serializer filters against, so a buffer record containing that key literally would still pass straight through. Nothing writes it today, so this was latent rather than live — but closing exactly this kind of door is what the allowlist is for, and the test only supplied ts so it could not catch it. Splits the two concepts: _INTERACTION_DATA_FIELDS stays the model-contract set (created_at IS a real InteractionData field, and the drift test pins the set against the model), while _WIRE_FIELDS is what the serializer filters on and excludes it. Why created_at must not be emitted: the extractor's bookmark is keyed on interaction created_at (last_processed_timestamp, compared with created_at >= ?), so a backdated batch — one recovered after the bookmark moved — is stored and then never extracted. That is permanent, silent loss of learning data on precisely the offline-recovery path this buffer exists for. Letting the server stamp its own time is the lesser evil until ingest ordering stops depending on caller-supplied event time. Caught by CodeRabbit on #144 after it merged.
1 parent ee602f1 commit fdbefb2

2 files changed

Lines changed: 29 additions & 3 deletions

File tree

plugin/src/claude_smart/state.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@
6262
}
6363
)
6464

65+
# Fields actually put on the wire. `created_at` is a real InteractionData field
66+
# — so it must stay in the set above, which is pinned against the model — but it
67+
# must NOT be emitted. Sending a buffered event time backdates the interaction,
68+
# and the extractor's bookmark is keyed on `created_at` (`created_at >= ?`), so a
69+
# batch recovered after the bookmark moved is stored and then never extracted:
70+
# permanent, silent loss of learning data on exactly the offline-recovery path
71+
# this buffer exists for. Letting the server stamp its own time is the lesser
72+
# evil until ingest ordering stops depending on caller-supplied event time.
73+
_WIRE_FIELDS = _INTERACTION_DATA_FIELDS - {"created_at"}
74+
6575
_VALID_CITATION_KINDS = frozenset(
6676
{"playbook", "profile", "user_playbook", "agent_playbook"}
6777
)
@@ -436,9 +446,7 @@ def unpublished_slice(
436446
# turn that rot into a publish failure this plugin's adapter swallows
437447
# without advancing its watermark.
438448
turn = {
439-
key: value
440-
for key, value in record.items()
441-
if key in _INTERACTION_DATA_FIELDS
449+
key: value for key, value in record.items() if key in _WIRE_FIELDS
442450
}
443451
turn["role"] = role
444452
# NOTE: deliberately does NOT send `created_at`. Carrying the buffer's

tests/test_state.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,24 @@ def test_allowlist_covers_every_installed_model_field(self):
544544
" the slicer would silently drop them"
545545
)
546546

547+
def test_created_at_is_never_emitted_even_when_present(self):
548+
"""A literal `created_at` in the buffer must not reach the wire.
549+
550+
The field is in the model-contract set (it is a real InteractionData
551+
field), so filtering on that set alone let it through. Only `ts` was
552+
tested, which could not catch this. Backdating an interaction hides it
553+
from the extractor permanently — see `_WIRE_FIELDS`.
554+
"""
555+
_, turns = state.unpublished_slice(
556+
[{"ts": 1, "role": "User", "content": "x", "created_at": 999}]
557+
)
558+
assert "created_at" not in turns[0], turns[0]
559+
560+
def test_wire_fields_excludes_created_at_but_contract_set_keeps_it(self):
561+
assert "created_at" in state._INTERACTION_DATA_FIELDS
562+
assert "created_at" not in state._WIRE_FIELDS
563+
assert state._WIRE_FIELDS < state._INTERACTION_DATA_FIELDS
564+
547565
def test_buffer_timestamp_is_not_sent(self):
548566
"""`created_at` must stay off the wire.
549567

0 commit comments

Comments
 (0)