Summary
decodeObservationPayload in internal/api/observation_handler.go reads phenomenonTime from the raw JSON map with raw["phenomenonTime"].(string). When the type assertion fails (e.g. client sent a numeric epoch), the field is silently dropped: no error returned, observation created with HTTP 201, phenomenonTime field discarded.
This is inconsistent with the sibling resultTime path, which catches the same wrong-type input via the late obs.ResultTime.IsZero() guard and returns 400 (see #3).
Surfaced during evaluation of #3.
Reproduction (HEAD, commit 4b994212)
curl -i -X POST "$BASE/datastreams/$DS/observations" \
-H "Content-Type: application/json" \
-d '{"resultTime":"2026-04-30T12:00:01Z","phenomenonTime":1773100000,"result":42}'
# → HTTP/2 201 — observation created
# GET shows phenomenonTime defaulted to resultTime (1773100000 was discarded)
Compare:
curl -i -X POST "$BASE/datastreams/$DS/observations" \
-H "Content-Type: application/json" \
-d '{"resultTime":1773100000,"result":42}'
# → HTTP/2 400 {"error":"resultTime is required"}
Same wrong-type input → opposite outcomes on sibling fields.
Code reference
// internal/api/observation_handler.go (around line 240)
if phenomenonTimeRaw, ok := raw["phenomenonTime"].(string); ok && phenomenonTimeRaw != "" {
phenomenonTime, err := time.Parse(time.RFC3339, phenomenonTimeRaw)
if err != nil {
return nil, &decodeError{msg: "Invalid phenomenonTime format"}
}
obs.PhenomenonTime = &phenomenonTime
}
// ← no else; control falls through; no late "must be string" guard
Impact
- Clients sending numeric/legacy phenomenonTime get an apparently-successful POST but the actual observation timestamp is lost.
- Inconsistent behaviour across
resultTime (rejected) and phenomenonTime (silently accepted then dropped) is the worst of both worlds — clients cannot infer one's behaviour from the other.
Suggested fix
Branch on type-assertion failure explicitly:
if v, present := raw["phenomenonTime"]; present && v != nil {
s, ok := v.(string)
if !ok {
return nil, &decodeError{msg: "phenomenonTime must be an RFC 3339 string"}
}
if s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil, &decodeError{msg: "Invalid phenomenonTime format: expected RFC 3339"}
}
obs.PhenomenonTime = &t
}
}
Apply the same shape to resultTime (which simultaneously fixes the misleading error message in the separate UX follow-up).
Related
Summary
decodeObservationPayloadininternal/api/observation_handler.goreadsphenomenonTimefrom the raw JSON map withraw["phenomenonTime"].(string). When the type assertion fails (e.g. client sent a numeric epoch), the field is silently dropped: no error returned, observation created with HTTP 201,phenomenonTimefield discarded.This is inconsistent with the sibling
resultTimepath, which catches the same wrong-type input via the lateobs.ResultTime.IsZero()guard and returns 400 (see #3).Surfaced during evaluation of #3.
Reproduction (HEAD, commit
4b994212)Compare:
Same wrong-type input → opposite outcomes on sibling fields.
Code reference
Impact
resultTime(rejected) andphenomenonTime(silently accepted then dropped) is the worst of both worlds — clients cannot infer one's behaviour from the other.Suggested fix
Branch on type-assertion failure explicitly:
Apply the same shape to
resultTime(which simultaneously fixes the misleading error message in the separate UX follow-up).Related
docs/research/issue-evaluations/issue-003.mdTimeRange.UnmarshalJSONsilent-swallow on Datastream POST