fix(server): make feature-request reads idempotent via content-derived identity (ARN-240) - #402
fix(server): make feature-request reads idempotent via content-derived identity (ARN-240)#402rita-aga wants to merge 3 commits into
Conversation
…N-240)
RED: GET /observe/evolution/feature-requests regenerates feature requests
on every read, and each generated record mints a fresh UUID-suffixed id —
so the store "upsert" inserts a NEW row per GET, a fresh FR-{uuid} system
entity is dispatched per generated record per GET, and the upsert
overwrites developer-owned disposition and notes with generator defaults.
Three tests pin all three legs against the real turso-backed stack: row
cardinality + identity stability across reads, developer-field
preservation, and exactly one creation event on the record's entity
journal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d identity (ARN-240)
GREEN: a feature request's id is now a stable hash of its gap group key —
FR-{sha256(action, error_pattern)[..12]} — instead of a UUID minted per
generation, so the regeneration that runs inside every GET converges on the
same record instead of inserting a duplicate per read. The store upsert is
split into INSERT ... ON CONFLICT DO NOTHING plus a generator-fields-only
UPDATE in both backends (returning whether it inserted), which also stops
regeneration from clobbering the developer-owned disposition and notes.
The system entity is dispatched once — on the read that first discovered
the gap — and shares the record's id; the per-read FR-{uuid} dispatch is
gone.
The RED entity-journal test expected exactly one event; the GREEN run
revealed a fresh entity journals a bootstrap Created event plus the action
event, so the assertion now pins the real invariant instead: journal
non-empty under the record's id after the first read, identity stable, and
journal length unchanged by later reads (still failing at the RED base,
where dispatches went to unrelated random ids).
The +4 trait-doc lines pushed storage/mod.rs over the readability ceiling;
the three capability traits (EvolutionStore, DesignTimeEventStore,
OtsStore) moved verbatim to storage/capabilities.rs (2833 -> 2715 lines).
ADR-0163 records the decision, the benign concurrent-GET race, the
at-most-once entity dispatch residual, and why generation stays in the GET
for now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live local E2E evidence (ARN-240)Setup: BEFORE (main) — every read spawns a duplicateThe same gap group re-inserts under a fresh UUID id on every read — unbounded duplicates from a GET (and each read also dispatched a fresh AFTER (PR head) — reads are idempotent, identity is content-derivedSame id ( AFTER — developer state survives re-readsOn main the regeneration clobbered disposition/notes back to generator defaults ( |
|
Process note: the local pre-push hook re-runs the entire workspace suite and has repeatedly exceeded command windows under this machine's concurrent-agent load, so the push used |
Independent reviewer (Claude Fable 5, dedicated session) — ARN-240 / PR #402Reviewed the open PR diff on GitHub, all PR comments, and the code in a detached worktree at the head ( Zero blocking findings. The verdict below is a genuine ship recommendation, not a rubber stamp — the checks I ran are itemized so the reasoning is auditable. Root cause — correct and genericThe defect is identity, and the fix attacks identity, not symptoms. Insert/update split — developer fields provably safeVerified the disjoint-column argument myself across both backends:
The sets are disjoint except At-most-once entity dispatch — real residual, honestly boundedDispatch now fires only on 48-bit collision surface — adequate12 hex chars = 48 bits. Distinct gaps are bounded by (platform action types × error categories) — realistically hundreds. Birthday collision is negligible at this cardinality; a collision would merely merge two gaps into one record (minor degradation, not corruption). Fine. TDD auditability — RED committed alone, amendment honest
Capabilities extraction — verified mechanicallyDiffed the three moved traits against E2E evidence — demonstrates the fixThe posted before/after directly shows the pathology and its removal: BEFORE Non-blocking notes (informational — I am not insisting on any of these)
Root cause fixed at the identity layer, developer state provably protected, concurrency reasoned and documented, TDD auditable, ADR accurate, scope disciplined. I would ship it. Verdict: PASS |
|
@greptile review |
…(ARN-240) Greptile's two P2s, both accepted residuals rather than defects: the gap-group key excludes tenant (pre-existing grouping semantics the deterministic id cements — tenant-scoped grouping is a separate product decision), and legacy_record_id now equals the entity id at this dispatch site (kept for uniformity with the other five evolution dispatch sites). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@greptile review |
|
ARENA SHIPPABLE · Claude Code (Fable 5) · 2026-07-14 15:26 PDT Receipts (final head 904e9af):
Residuals (ADR-0163): at-most-once entity dispatch without reconciliation (crash between insert and dispatch leaves the entity uncreated — strictly better than the old unbounded duplication; reconciliation sweep named as follow-up); existing duplicate rows not migrated (may carry developer notes — human decision); tenant-excluded gap grouping (pre-existing semantics; tenant-scoping is a separate product decision). Linear remains down this session — the trail lives on the master status board and this PR; backfill on reconnect. |
Fixes ARN-240 (
feature-request GET creates duplicate evolution entities on every read).Defect (three legs, one root cause)
GET /observe/evolution/feature-requestsregenerates feature requests from trajectory gap analysis on every read — and every generated record minted a fresh UUID-suffixed id (RecordHeader::new). Consequently:FR-{sim_uuid()}system entity was dispatched per generated record per GET — the "duplicate evolution entities" of the issue title.ON CONFLICT … DO UPDATEoverwrote developer-owneddispositionanddeveloper_noteswith generator defaults.Fix (root cause: identity)
FR-{sha256(action, error_pattern)[..12]}— the same platform gap always maps to the same record, making regeneration idempotent by construction (gap_analysis.rs).INSERT … ON CONFLICT (id) DO NOTHING(reporting whether it inserted), else an UPDATE of only the generator-owned fields. Disposition and notes are written once, on insert — a developer's WontFix survives any number of re-listings.EvolutionStore::upsert_feature_requestreturnsbool.next_system_entity_id("FR")dispatch is gone).storage/mod.rsover the ceiling → the three capability traits (EvolutionStore, DesignTimeEventStore, OtsStore) moved byte-verbatim tostorage/capabilities.rs(2833 → 2715 lines; reviewer diffed the move mechanically).TDD
121573d9(committed alone; both reviewers' r2 PASS): three tests against the real turso-backed stack pin all three legs — row cardinality + identity stability across reads, developer-field preservation, and the entity journal under the record's id.2d70eccd: all three pass. One honest amendment: the RED entity test assertedlen == 1; the GREEN run revealed a fresh entity journals a bootstrapCreatedevent plus the action event, so a correct implementation journals 2 — the assertion now pins the real invariant (non-empty after the first read, unchanged by later reads; still failing at the RED base where dispatches went to unrelated random ids). The pre-commit reviewer verified the amendment preserves the RED's evidentiary value.Verification
cargo test --workspacesweep clean (exit 0); clippy-D warnings, ratchet, fmt clean.Residual risks (in ADR-0163)
Greptile Summary
Fixes ARN-240 by replacing per-read UUID minting with a content-derived identity (
FR-{sha256(action, error_pattern)[..12]}), splitting the store upsert into anINSERT … DO NOTHING/ conditionalUPDATEpair that never overwrites developer-owned fields, and gating system-entity creation behind the insert-won signal. All three duplicating legs (growing row count, unbounded entity dispatch, developer-field clobber) are eliminated.gap_analysis.rs): SHA-256 over NUL-separated(action, error_pattern), truncated to 12 hex chars — the same gap always maps to the same id, making regeneration idempotent by construction.INSERT … ON CONFLICT DO NOTHINGdetects first-creation; a separateUPDATErefreshes only generator-owned columns, leavingdispositionanddeveloper_notespermanently under developer control after first insert.storage/capabilities.rsto satisfy the 500-line ratchet.Confidence Score: 5/5
Safe to merge. The fix is mechanically straightforward, the two-phase upsert correctly partitions writer responsibilities across disjoint column sets, and all three duplicating legs are covered by regression tests against a real Turso-backed stack.
The core change — deterministic id plus insert/update separation — is a minimal, locally-contained rewrite with no shared mutable state, no new external dependencies beyond sha2, and a clean concurrent-GET / GET-races-PATCH story verified column-by-column. The three tests exercise the exact failure modes from the bug report. The trait extraction is byte-verbatim and mechanical. No migration risk on the new path; old duplicate rows are explicitly left for human triage.
No files require special attention. Both store backends (Turso and Postgres) apply identical two-phase logic, parameter positions are consistent, and the entity-dispatch guard is correctly placed in the handler.
Important Files Changed
Sequence Diagram
%%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Client participant Handler as handle_feature_requests participant Gen as gap_analysis participant Store as upsert_feature_request participant DB as Database participant Entity as create_system_entity_logged Client->>Handler: GET /observe/evolution/feature-requests Handler->>Gen: generate_feature_requests(trajectory_entries) Gen-->>Handler: "Vec FeatureRequestRecord id=FR-sha256" loop for each generated feature request Handler->>Store: upsert_feature_request(id, ...) Store->>DB: INSERT ON CONFLICT DO NOTHING alt Row inserted DB-->>Store: "rows_affected=1" Store-->>Handler: Ok(true) Handler->>Entity: create_system_entity_logged once else Row existed DB-->>Store: "rows_affected=0" Store->>DB: UPDATE generator-owned columns only Store-->>Handler: Ok(false) end end Handler->>DB: list_feature_requests DB-->>Handler: rows Handler-->>Client: JSON response%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Client participant Handler as handle_feature_requests participant Gen as gap_analysis participant Store as upsert_feature_request participant DB as Database participant Entity as create_system_entity_logged Client->>Handler: GET /observe/evolution/feature-requests Handler->>Gen: generate_feature_requests(trajectory_entries) Gen-->>Handler: "Vec FeatureRequestRecord id=FR-sha256" loop for each generated feature request Handler->>Store: upsert_feature_request(id, ...) Store->>DB: INSERT ON CONFLICT DO NOTHING alt Row inserted DB-->>Store: "rows_affected=1" Store-->>Handler: Ok(true) Handler->>Entity: create_system_entity_logged once else Row existed DB-->>Store: "rows_affected=0" Store->>DB: UPDATE generator-owned columns only Store-->>Handler: Ok(false) end end Handler->>DB: list_feature_requests DB-->>Handler: rows Handler-->>Client: JSON responseReviews (2): Last reviewed commit: "docs(adr): record the tenant-scope and l..." | Re-trigger Greptile