fix(models): delegate fire_at ISO validation to parse_iso_timestamp (#1831) - #1940
Conversation
…1831) `ReminderCreate._check_fire_at_iso` re-implemented ISO normalization as `v.replace("Z", "+00:00")` — EVERY 'Z' — while `parse_iso_timestamp` strips only a TRAILING 'Z'. A mid-string 'Z' therefore passed the validator and then raised `ValueError` out of the UNGUARDED `reminder_service._resolve_fire_at`. The router rescues only `HTTPException`, so an agent-supplied `set_reminder(fire_at=...)` returned HTTP 500. Measured on the production interpreter (py3.13.5), the class is seven inputs wide, each independently confirmed a live 500 through the real code: '2026-01-15 10:30Z:00' '2026-01-15 10Z:30:00' '2026-01-15T10:30:00Z.5' '2026-01-15T10:30Z:00' '2026-01-15T10:30Z:00.5' '2026-01-15T10Z:30' '2026-01-15T10Z:30:00' The validator now calls the same parser the service uses, so "validator accepted" implies "parser can parse" BY CONSTRUCTION rather than by two hand-maintained normalizations that happen to agree. It cannot re-diverge under a future edit to either side. No regression is possible by construction: newly-rejected == {old validator accepts} AND {parser raises} == exactly today's 500 set. Deliberate loosening: an uppercase 'Z' used as the date/time SEPARATOR is now accepted (it always parsed fine; only the `replace` bug mangled it), matching the lowercase 'z' and 'X' separators `fromisoformat` already accepts today. `return v` stays byte-identical — the create-idempotency key hashes `raw_fire_spec()` (Invariant #18), so canonicalizing here would fork the key and double-create on a client retry. Rejection now lands during Pydantic body binding, i.e. BEFORE `idempotency_service.begin()`, so this instance of the stranded `in_flight` claim (which converted every identical retry into a 409 for 24h) disappears structurally. P7 in tests/unit/test_1771b_timestamp_helpers_properties.py flips from a strict xfail to a real pass. Its `assume()` is replaced with an early `return`: post-fix ~79% of draws are filtered, which trips `FailedHealthCheck: filter_too_much` under the derandomized `ci` profile (which does not suppress it). Returning early makes the health check structurally unreachable while asserting the same implication. Refs #1831 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six regression tests next to the ReminderCreate validation they protect: T1 test_1831_mid_string_z_fire_at_rejected — the seven measured mid-string-Z inputs, each a confirmed live 500 before the fix. Also the positive pin for the four historical @example inputs that P7 can no longer make deterministic (post-fix they are filtered draws). T2 test_1831_validator_does_not_canonicalize_fire_at — the Invariant #18 guard: fire_at and raw_fire_spec() round-trip byte-identically, so a later "helpful" normalization that would fork the idempotency key and double-create on retry fails loudly. T3 test_1831_validator_implies_parser — P7 restated discretely and Hypothesis-free, so the implication survives if the property suite is ever deselected. One-directional on purpose: the converse would forbid a future legitimate tightening of the validator. T4 test_1831_uppercase_z_separator_now_accepted — pins the deliberate loosening as intended, so it is not "fixed" back. T5 test_1831_fire_at_none_cannot_reach_the_parser — the XOR guard is the only thing between fire_at=None and an AttributeError 500; nothing tested that it is load-bearing. T6 test_1831_malformed_fire_at_never_claims_an_idempotency_key — rejection must land at body binding, BEFORE idempotency_service.begin(), so no in_flight claim is stranded into a 24h 409 wedge. Pins the fix's second-order value against a refactor that moves validation back into the service. Non-vacuity verified by reverting the product fix: 29 of the 56 parametrized cases fail without it (and P7 fails), all 56 pass with it. Corpora are interpreter-portable — the parser's verdict on all three families is identical on py3.13.5 and py3.14.3. Refs #1831 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
models.py now contains two ISO validators with deliberately different
normalizations: the fixed _check_fire_at_iso (delegates) and
_validate_iso8601 (still carries the old replace("Z","+00:00"), left
alone — its values are provably never re-parsed, so there is no live
defect). That asymmetry reads as an inconsistency and invites a future
"cleanup" to re-introduce the bug, so the delegation is stated
explicitly here alongside the inline code comment.
Refs #1831
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/sync-feature-flows caught the one genuine staleness the code change introduced: the Tests section pinned `tests/unit/test_1296_reminders.py` at 33 collected cases; it is now 89 (the six #1831 tests parametrize to 56). Records what the new cases cover and adds the P7 property row, whose status this issue flips from strict-xfail to a real pass. Checked and confirmed NOT stale: the other three docs referencing `parse_iso_timestamp` (scheduler-service.md:778, activity-stream.md:761 and architecture.md Invariant #16) all describe the scheduler mirror and the db read boundaries — `parse_iso_timestamp` itself is unchanged, this issue only adds a caller. No index row added (no new flow, no name/description change). Refs #1831 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Post-fix P7's four @examples are all rejected, so nothing but a docstring stood between a future edit and a green-but-worthless property. Measured over the full _Z_INJECTED (base, i) grid on py3.11/3.13/3.14: every accepted draw comes from i >= len(base) — a degenerate trailing-'Z' append — and zero true mid-string insertions are accepted. So narrowing st.integers(max_value=27) to the base lengths (a plausible "magic number" cleanup) would drop acceptance to 0% silently. - add a fifth @example that IS accepted (a member of the uppercase-'Z'-as- separator class #1831 deliberately started accepting), so the assertion body is guaranteed to execute at least once whatever the strategy becomes; - state the measured shape of the ~21% acceptance in the docstring instead of letting the percentage read as broader coverage than it is; - put the "NOT assume()" warning at the early-return itself, not only in the docstring, since that is where a tidy-up pass looks. Tests only. P7 still PASSED, 172 passing / 0 invalid / 20.93% accepted; the five affected suites are 190 passed, 0 xfailed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A property shipped as a strict xfail with an assume() gate flips its own filter economics the moment the bug it documents is fixed — the shared ci profile does not suppress filter_too_much, so the intended pass is a deterministic red. Records the early-return remedy over suppress_health_check (structurally unreachable vs hidden, and it removes the derandomize source-digest coin flip rather than re-rolling it), and the separate lesson that an event() acceptance percentage is a rate, not coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bullet read the acceptance percentage as a non-vacuity guarantee. Measured over the full _Z_INJECTED grid, every accepted draw is the strategy's degenerate trailing-'Z' tail — it is a rate, not coverage — and the pinned accepted @example is now the mechanical floor. Points readers at test_1296_reminders.py as the deterministic regression guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Resolve by running |
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Review — /review + /validate-pr, second pass
Read-only against origin/dev @ 8e924526. Head ff0004b4 unchanged since the first pass. Reviewed together with #1938 — the two are coupled, but only by merge order (see below).
Verdict: APPROVE-grade diff. No code change requested. Two behavioural lines that replace a hand-maintained normalizer with the exact parser its only consumer already calls, making "the validator accepted it" and "the parser can parse it" the same statement by construction rather than two rules that happened to agree. Posting as a comment rather than an approval only because the discovered follow-ups are still unfiled.
Re-verified this pass and holding:
- Invariant #16 / scheduler mirror intact.
src/backend/utils/helpers.pyandsrc/scheduler/utils.pyare both absent from the diff (emptygit diffon each) — the PR adds a caller, nothing the mirror must agree on moved. And the widened class is structurally unreachable from the mirror anyway:reminder_service.py:138persiststo_utc_iso(fire_dt), soparse_scheduler_tsnever sees aZ-separator string. - Invariant #14 intact. No file under
routers/appears in the diff at all, so noBaseModelwas added there; thetest_models_centralized.pyguard surface is untouched. - Coupling with #1938 is merge-order only.
comm -12on the two PRs' file lists returns exactly one shared path —docs/memory/learnings.md. No shared symbol, import, or behavioural ordering.
The adjacent bug this PR surfaced is real — please file it
Follow-up 2 in the body (models.py:402) is not hypothetical. I reproduced it, and it fails in both directions.
src/backend/models.py:400-404 compares raw, un-normalized ISO strings — _validate_iso8601 (models.py:360-368) validates and then return value unchanged, so the separator and offset spelling are entirely caller-controlled:
@model_validator(mode="after")
def _check_period_order(self) -> "ReportCreate":
if self.period_start and self.period_end and self.period_start > self.period_end:
raise ValueError("period_start must be <= period_end")case lexical real verdict
T-vs-space separator True False FALSE 422 (valid range rejected)
T-vs-space, INVERTED False True INVERTED RANGE ACCEPTED
same instant, diff offset True False FALSE 422 (valid range rejected)
cross-midnight w/ offset False True INVERTED RANGE ACCEPTED
control: both canonical False False ok
Concretely — both values pass the field validator in each case:
period_start='2026-01-15 23:00:00+00:00',period_end='2026-01-15T09:00:00Z'→' ' < 'T', order check passes, a 14-hour-inverted range is persisted toagent_reports.period_start='2026-01-15T10:00:00+00:00',period_end='2026-01-15T05:00:00-05:00'→ the same instant, rejected 422.
Blast radius is contained and the body's audit conclusion is correct — I independently confirmed the values are opaque TEXT pass-through across all 28 period_start/period_end references, none of which call fromisoformat/parse_iso_timestamp/strptime. So the consequence is a wrong 422 or a silently-inverted stored range, not a crash. Genuinely latent.
This is out of scope for this PR and should not be fixed in it — same trap (Invariant #16), different surface, and #1831 is a 2-line P2. But three tracker searches (_validate_iso8601, the period compare, 1831 follow-up) all return empty, and everything created since 2026-08-01 (#1931–#1933) is unrelated. Suggested framing: type-bug, priority-p2, theme-reliability, complexity-low.
Follow-up 3 (the stranded in_flight idempotency claim) is worth filing on the same pass.
Merge order
Confirmed by git merge-tree, not inferred:
#1938 into current dev -> clean
#1940 into (dev + #1938) -> CONFLICT, docs/memory/learnings.md ONLY
The body's merge-order warning is correct but under-scoped: four PRs append at the same EOF hunk, not two — #1938, #1911 and this one at @@ -230,3 +230,7 @@, #1913 at @@ -230,3 +230,11 @@. So the conflict is guaranteed in every ordering, and hand-resolution is required regardless of who lands first.
It is, however, a trivial conflict: one file, both sides disjoint appends to an append-only ledger. Resolution is concatenate, keep both, in merge order — no semantic decision, no content to reconcile. The only real hazard is an auto-resolver or a careless --ours/--theirs dropping an entry. Verify after each with:
git show dev:docs/memory/learnings.md | grep -c '^## 20'Suggest letting #1938 (P1, currently conflict-free) land first, then rebasing this one.
Pre-merge checklist
- File follow-up 2 (
models.py:402lexicographic period compare — verified live above) astype-bug - File follow-up 3 (stranded
in_flightidempotency claim) - Correct the merge-order note: #1938, #1911 and #1913 share the anchor, not #1849 alone
- Optional: reword "the class is seven inputs wide" → "seven pinned exemplars of an unbounded class" (a wider corpus finds 10 on py3.13; doesn't weaken the fix, which is by construction, but the prose reads as a closed set)
- Second reviewer per SOC 2 (this is a comment, not an approval)
Full working notes, including the reproduction script behind the table above, are in my local review record. Happy to paste it into the thread.
AndriiPasternak31
left a comment
There was a problem hiding this comment.
Approving — ff0004b4
Converting my earlier review to an approval. Nothing in the diff changed; my
re-verification of Invariant #16 (scheduler mirror untouched, and structurally
unreachable from this path since reminder_service.py:138 persists
to_utc_iso(fire_dt)) and Invariant #14 (no routers/ file in the diff) stands.
The two behavioural lines replace a hand-maintained normalizer with the exact
parser its only consumer already calls, so "the validator accepted it" and "the
parser can parse it" become the same statement by construction. That's the right
shape of fix.
The open items are administrative, not code, so they should not gate the diff:
- Follow-up 2 (
models.py:402lexicographic period compare) and follow-up 3
(strandedin_flightidempotency claim) are both pre-existing defects
surfaced by this review — neither is introduced or worsened here. They belong
in the tracker, not in this PR. Still worth filing. - The merge-order note in the body under-scopes the
learnings.mdanchor:
#1938, #1911 and #1913 share it, not #1849 alone. The conflict is guaranteed
in every ordering and is a keep-both concatenation of an append-only ledger —
no semantic decision. Verify after resolving with
git show dev:docs/memory/learnings.md | grep -c '^## 20'.
Note dev has moved twice today (#1935 609f8dff, #1934 f5a21624); I
re-simulated this branch against the new tip and it still merges clean.
Fixes #1831
What broke
ReminderCreate._check_fire_at_isoandreminder_service._resolve_fire_atwere two hand-maintained normalizers that disagreed. The validator didv.replace("Z", "+00:00")— replacing everyZ— whileutils.helpers.parse_iso_timestampstrips only a trailingZ. So afire_atcarrying a mid-stringZpassed validation, then hit the service's unguardedparse_iso_timestamp(data.fire_at)call and escaped as an uncaughtValueError→ HTTP 500.fire_atis agent-supplied through the MCPset_remindertool (z.string().optional()), so the malformed value arrives from outside the platform.The fix
src/backend/models.py— the validator now calls the same parser the service uses, so "the validator accepted it" and "the parser can parse it" are the same statement rather than two rules that happened to agree. ~2 lines of behaviour plus the comments that keep it that way.models.py:13already imported fromutils.helpers(stdlib-only), so there is no new dependency and no circular-import risk.This also makes PR #1826's
strict=Truexfail —test_P7_validator_acceptance_implies_the_parser_can_parse— hold by construction; the xfail is deleted in the same commit (a strict XPASS is a CI failure).Read this before reviewing: five things that are decisions, not gaps
1. The resolved status code is 422, not the issue title's "400" — and that is NOT an unmet AC
A
@field_validatorraisingValueErrorsurfaces asRequestValidationError→ 422. Verified first-hand: noRequestValidationErrorhandler and noexception_handler/add_exception_handleroverride exists insrc/backend/main.py, so FastAPI's default applies.422 is also this endpoint's own documented field-level convention (
docs/memory/feature-flows/agent-self-reminders.md:49, andarchitecture.md:546already recordsmessage ≤4000 (422)). Forcing 400 would mean moving validation into the service — reintroducing the very validator/parser divergence this issue closes — and would land the rejection afteridempotency_service.begin(), i.e. after a claim has already been staked.2. The issue's second ask was performed — outcome: latent, deliberately deferred
The issue also asks to "audit
models.py:358replace("Z","+00:00")for the same divergence." That audit ran. Result: the bug shape is present but latent — those values are never re-parsed._validate_iso8601(models.py:360-368) backsReportCreate.period_start/period_end. Grep-confirmed end to end: they are stored asTEXT(db/schema.py:499-500,migrations/versions/0006_agent_reports.py:38-39), passed throughrouters/reports.py:96-97→database.py:1502→db/reports.pyas opaque strings, and rendered raw. Nothing callsfromisoformaton them anywhere but the validator itself, so there is no second parser to diverge from — no live defect, unlikefire_at.Saying this plainly so it is not read as an unmet AC: the second ask was answered, not skipped. It is carried as follow-ups (1) and (2) below — and (2) is arguably the more serious defect on that same model.
3. Scope honesty — this kills this instance, not the class
Rejection now happens in the Pydantic validator, i.e. before
idempotency_service.begin(). That removes this instance of the stranded-in_flight→ 24h-409 wedge: previously a malformedfire_atclaimed an idempotency key, then 500'd out withoutcomplete()orrelease(), so every retry for the next 24 hours got a 409.It does not fix the class. Any other non-
HTTPExceptionescape betweenbegin()and the terminal — asqlite3error, a Redis error, a futureAttributeError— strands identically. And it must not be "fixed" with a blanketexcept Exceptionaround the dispatch; see the standing comment atrouters/reminders.py:157-161. The correct shape is a narrow pre-insert rescue. That is worth its own issue (follow-up 3).4. No regression, argued BY CONSTRUCTION — not by corpus
A corpus argument is precisely what had a gap here in the first place, so the claim is structural:
An input in that set does not work today — it 500s. Therefore no input that works today stops working.
Independently re-verified at ship time over a freshly generated 658-input corpus (canonical shapes × every insertion index ×
{Z, z, T, X, space}, plus garbage):newly_rejected == broken_today→ True; inputs that work end-to-end today → 19, of which 0 are now rejected.5. The deliberate loosening: uppercase
Zas the date/time separator2026-01-15Z10:30:00is now accepted. LowercasezandXin that position already were — uppercaseZwas rejected solely as an artifact of thereplacebug ("…15Z10:30:00".replace("Z","+00:00")→…15+00:0010:30:00, garbage). Measured at ship time:2026-01-15Z10:30:002026-01-15z10:30:002026-01-15X10:30:00This is a loosening, not a regression, and it is pinned as intended by
test_1831_uppercase_z_separator_now_accepted.Scope of the divergence class
The issue names four inputs. Measured, the class is seven inputs wide on py3.13 (the prod interpreter), narrowing to four on py3.14 as
fromisoformattightens. The corpus was additionally measured portable to py3.11 — the CI interpreter — with byte-identical results.Interpreter-parity gap worth your attention (pre-existing, not introduced by this PR)
.github/workflows/backend-unit-test.ymlruns Python 3.11 (threesetup-pythonsteps, all'3.11') while the prod image is Python 3.13 (docker/backend/Dockerfile:1→FROM python:3.13-slim), andverify-localbuilds/tests the 3.13 image. A greenverify-localtherefore does not cover the CI interpreter, and vice versa — andfromisoformatacceptance is exactly the kind of behaviour that moves between minor versions.Closed for this change by re-running the entire corpus on 3.11.14: all 7 mid-
Zrejected, all 7 Z-separator accepted, all 7 canonical accepted, all 5 garbage rejected — byte-identical to 3.13 and 3.14. (This run also observed verify-local's own venv on 3.14.3 — three interpreters gating one codebase.) Logged as follow-up 6.return vbyte-identity is load-bearingThe validator returns
vunchanged — never canonicalized. The create-idempotency key hashesraw_fire_spec()==f"fire_at={self.fire_at}"(models.py:2198-2204) →derive_reminder_key→idempotency_service.begin(). Canonicalizing here would fork the derived key and double-create on a client retry (Invariant #18). A dedicated test pins it (test_1831_validator_does_not_canonicalize_fire_at), and a comment at thereturnsays why.Pre-existing and by design, so review doesn't raise it as new: keying on the raw spec means two spellings of the same instant hash differently. That equivalence class was already unbounded; the Z-separator family adds three members to it. Unchanged by this PR — the alternative (key on the resolved instant) is what Invariant #18's raw-input rule deliberately rejects.
The
_validate_iso8601fence: HELD, on scope grounds onlyStating this plainly: the original merge-conflict justification for the fence was measured false and is retracted. PR #1838's hunk ends around line 354 and the target is line 365 — it would merge cleanly.
The fence is held on the remaining merits: engineering proved those values are never re-parsed (latent, not live — see #2 above), and the more serious defect on that same model is follow-up 2 below. Fixing
_validate_iso8601alone would ship the less important half of a two-part fix, on a field another feature owns.Consequently the proposed
"fromisoformat" not in models.pystatic parity guard is deliberately not shipped — with the fence held it would fail immediately againstmodels.py:365. It belongs with follow-up 1.Test evidence
origin/devbaseline133 passed, 1 xfailed(the xfail is P7)PASSED—172 passing, 0 failing, 0 invalid,20.93% validator accepted29 failed, 27 passed; P71 failed— the tests provably detect the bugmodels1060 passed, 1 skippedpytest-randomlyCI seeds190 passedeach(ValueError, AttributeError)tests/unit(WAVE-4verify-local)1 failed, 6088 passed, 18 skipped— see belowpytest tests/unit/test_1771b_timestamp_helpers_properties.py \ tests/unit/test_1771b_timestamp_helpers_edges.py \ tests/unit/test_1296_reminders.py \ tests/unit/test_1806_reminder_autonomy_hold.py \ tests/unit/test_1713_scheduler_utils_parity.py -q -rsSix new
test_1831_*regression tests intests/unit/test_1296_reminders.py(56 cases of the file's 89): the seven measured mid-string-Zinputs rejected ·fire_at/raw_fire_spec()byte-identity · validator ⟹ parser one-directionally over a fixed corpus · the uppercase-Zloosening pinned as intended · the XOR guard proven load-bearing againstparse_iso_timestamp(None)· rejection landing beforeidempotency_service.begin().P7's non-vacuity now has a mechanical floor — and why it needed one
The
20.93% validator acceptedfigure was degenerate. Measured across 3.11/3.13/3.14, 100% of accepted draws came fromi >= len(base)— a plain trailing-Zappend — and zero true mid-string insertions were accepted. So "21%" read as coverage while actually exercising three canonical strings. A concrete drift path existed: narrowingst.integers(max_value=27)to the base lengths — a plausible magic-number cleanup — would silently drop acceptance to 0% and leave the property green and worthless.Fixed with a fifth, accepted
@example("2026-01-15Z10:30:00") as a floor, verified at ship time to be accepted by the validator, so the assertion body executes at least once regardless of what the strategy later becomes.Separately, the
# NOT assume()warning was moved out of the docstring to thereturnsite: reverting the early-returntoassume(_validator_accepts(raw))reintroduces a deterministicFailedHealthCheck: filter_too_much(~79% of draws now filtered; the sharedciprofile suppresses onlytoo_slow/data_too_large) and re-arms source-digest seed sensitivity underderandomize=True. A comment two paragraphs into a docstring does not survive that edit; one at thereturndoes.Verification honesty
verify-localran--skip-agent— a host constraint, not a passing result:origin/devis global agent-network mode and the operator's live dev stack ownstrinity-agent-network, so the agent stage hard-refuses.from utils.helpers import parse_iso_timestamptomodels.py, which is imported on the backend startup path; boot + health 13s; integration 70 passed.tests/unit/test_1069_voip_call_path_param.py::TestFlatPathParams::test_flat_path_params_are_agent_name_not_namereproduces identically on a pristinegit archive origin/dev@8e924526in the same venv. Repo-level root cause:fastapi>=0.115.0is a floor, not a pin, so any fresh venv resolves 0.141.1, whereget_flat_dependantno longer exists. It will red for every contributor building a clean env — its own issue, and must not be attributed to this PR.docs/memory/learnings.mdwill conflict with the PR for #1849. Both append a new## 2026-08-01 — pitfall — …block at EOF, patching the identical hunk@@ -230,3 +230,7 @@with the same three trailing context lines. Git's 3-way merge cannot auto-resolve two insertions at one anchor.Merge #1849 first (it is P1); this PR then rebases and keeps both blocks (either order). Resolve by hand.
Follow-ups (recorded here, deliberately not filed)
_validate_iso8601still carries the bug shape.models.py:360-368keepsvalue.replace("Z", "+00:00"). Ship it together with (2), plus the"fromisoformat" not in models.pystatic parity guard, as one reports-owned issue.ReportCreate._check_period_ordercomparesperiod_start > period_endLEXICOGRAPHICALLY on raw, un-normalized strings (models.py:402). This is exactly the mixed-shape trap Invariant #16 exists to prevent —2026-01-15T10:00:00Zvs2026-01-15 10:00:00+00:00compare wrong (T> space). Arguably more real than (1).HTTPExceptionescape betweenbegin()and the terminal strands a claim for 24h. Needs a narrow pre-insert rescue — not a blanketexcept Exception..replace("Z", "+00:00")at 11 further backend sites. All verified to consume platform-written input → hygiene, not urgency._validator_acceptscatches bareException(pre-existing, inherited from test(timestamps): edge-case and property coverage for the ISO helpers and their scheduler mirror #1826) — narrow it toValidationError.Plus, from
/validate-pr, disclosed rather than "fixed":docs/memory/feature-flows.mdRecent Updates row was added. The check requires one only for a new flow (this is an update), CLAUDE.md Rule fix: add missing logging_config.py to backend Dockerfile #4 scopes a bug fix to a commit message, and adding one would manufacture a second guaranteed merge conflict with bug(whatsapp): inbound media download always fails — SSRF allowlist rejects Twilio's own media CDN #1932, which already appended at the same top-of-table anchor.architecture.md:1079's error list gains no 422 note. The whole Agent Self-Reminders section was read: it makes no claim about the normalization algorithm and no claim that a malformedfire_atreturns 400, so nothing in it is falsified.agent-self-reminders.mddoes not match the canonical flow template (## Testsnot## Testing, no Frontend Layer — there is no frontend surface). Pre-existing from feat: agent self-reminders — agent-callable one-shot deferred self-trigger ("remind me to do X later") #1296; both edited sections exist. Restructuring would be scope creep on a P2 bug fix.🤖 Generated with Claude Code