Skip to content

fix(server): make feature-request reads idempotent (ARN-240) - #385

Closed
nerdsane wants to merge 6 commits into
mainfrom
codex/arn-240-feature-request-get-idempotency
Closed

fix(server): make feature-request reads idempotent (ARN-240)#385
nerdsane wants to merge 6 commits into
mainfrom
codex/arn-240-feature-request-get-idempotency

Conversation

@nerdsane

@nerdsane nerdsane commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • make GET /observe/evolution/feature-requests a projection-only read with no durable writes
  • materialize feature requests explicitly during the authorized Sentinel command
  • derive stable entity IDs and durable idempotency keys from tenant, generator version, and canonical evidence
  • preserve human disposition and exact developer-note bytes when generated projection fields refresh
  • safely reconcile legacy random-ID projections only when their trajectory evidence proves one source tenant
  • normalize generator output so equivalent evidence orderings produce the same revision

Root cause

The GET handler generated feature requests and wrote both projection rows and FeatureRequest actor events on every read. Generated record IDs and actor IDs were fresh UUIDs, so repeated or concurrent reads created durable duplicates.

Design and tradeoffs

Materialization now belongs to POST /api/evolution/sentinel/check, the existing authorized command boundary. The stable revision identity is SHA-256 over the source tenant, an explicit generator version, category, canonical description, frequency, and sorted trajectory references. Changed evidence or a changed generator version intentionally creates a new revision; an exact retry reuses the same actor ID and durable idempotency key.

Generated fields may refresh on an existing projection, but conflict updates deliberately retain human-owned disposition and developer notes. Legacy FR-YYYY-<12 hex> projections are migrated to the stable revision without losing review state. Reconciliation is action-disambiguated and retry-safe after partial cleanup: the canonical note blob is preserved byte-for-byte, while genuinely missing legacy note components append deterministically.

The canonical FeatureRequest entity lives in the temper-system control-plane tenant, while the source tenant is filtered before generation and salted into the identity. Because legacy rows did not store tenant IDs, they are migrated only if every referenced trajectory is present and all refs resolve exclusively to the active source tenant. Ambiguous, malformed, incomplete, or mixed-tenant legacy evidence is deliberately left untouched for human resolution instead of guessed or deleted.

No ADR was added: this is a localized correctness/durability repair using the existing Sentinel command, actor dispatch, durable idempotency, and metadata-store abstractions rather than introducing a new architectural pattern.

RED evidence

RED-only commit: 5338da03

cargo test -p temper-server --features observe feature_request_get_is_a_pure_read -- --nocapture
first GET returned total = 1, expected 0

Validation

  • CARGO_INCREMENTAL=0 cargo test -p temper-server --features observe feature_request -- --nocapture — 8 passed, 0 failed
  • CARGO_INCREMENTAL=0 cargo clippy -p temper-server -p temper-store-turso -p temper-store-postgres --all-targets --all-features -- -D warnings — passed (full touched-crate gate); final server-only follow-ups rechecked with the same strict flags
  • cargo fmt --all -- --check — passed
  • git diff --check — passed
  • readability ratchet — blocking metrics passed at baseline
  • code-quality review — PASS marker refreshed for final head
  • DST review — PASS marker refreshed for final head
  • fresh GPT-5.6 GitHub whole-diff review — Verdict: PASS
  • live CLI/server/Turso HTTP + process-restart proof — exact commands and output

The local workspace suite compiled successfully and passed all completed groups, but the untouched temper-actor-runtime integration binary failed before test logic because Docker timed out creating all five Postgres testcontainers (CreateContainer(RequestTimeoutError)). A serialized retry reproduced the Docker startup failure. GitHub CI on clean runners is the authoritative full-suite gate.

Scope

No changes to crates/temper-actor-runtime.

Linear: ARN-240

Greptile Summary

This PR moves feature-request materialization out of the GET handler and into the authorized sentinel command, so reads are now projection-only with no durable side effects. Stable, content-addressed IDs (SHA-256 over tenant, generator version, category, description, frequency, and sorted trajectory refs) replace the previously random UUIDs, and durable idempotency keys prevent duplicate actor events on retry or concurrent invocations.

  • gap_analysis.rs: Normalizes description to the lexicographically smallest error string in each group, and sorts trajectory timestamps before hashing, eliminating two sources of order-dependence in the stable ID.
  • operations.rs + reconcile.rs: Adds materialize_feature_requests (called only from sentinel) that upserts stable projections, dispatches actor events with idempotency keys, and reconciles legacy projections — safely skipping ambiguous or mixed-tenant rows.
  • storage/evolution.rs: Extracts the EvolutionStore trait and adds delete_feature_request; the upsert SQL is narrowed to preserve disposition and developer_notes on conflict.

Confidence Score: 4/5

Safe to merge once the two open issues from prior review rounds are resolved.

The core correctness fix is sound and well-tested with five focused integration tests. Two issues flagged in earlier rounds remain open: the description accumulator starts at empty string when the first entry has no error, so the normalization loop never fires and the stable ID varies by arrival order; and format!("{:?}", category) is still used for the stored category column in reconcile.rs.

gap_analysis.rs (description accumulator initialization) and reconcile.rs (category string via Debug format)

Important Files Changed

Filename Overview
crates/temper-server/src/observe/evolution/insight_generator/gap_analysis.rs Adds timestamp sorting and minimum-error-string normalization; the description accumulator initialization bug (empty string when first entry has no error) remains and breaks the order-independence invariant for affected groups.
crates/temper-server/src/observe/evolution/operations.rs Introduces stable_feature_request_id with explicit category keys, moves all writes to materialize_feature_requests, and removes writes from the GET handler. Category column in projection still uses Debug format.
crates/temper-server/src/observe/evolution/operations/reconcile.rs New file for legacy migration. Correctly gates on single-tenant unambiguous evidence, preserves canonical notes byte-for-byte, and is retry-safe. Uses Debug format for category matching.
crates/temper-server/src/observe/evolution/operations/support.rs Adds dispatch_system_action_idempotent enabling deduplication of CreateFeatureRequest actor events across concurrent or retried sentinel invocations.
crates/temper-server/src/storage/evolution.rs Extracts the EvolutionStore trait into its own module and adds delete_feature_request to both Postgres and Turso impls.
crates/temper-store-postgres/src/platform.rs Narrows upsert ON CONFLICT to exclude disposition and developer_notes, adds delete_feature_request.
crates/temper-store-turso/src/store/evolution.rs Same upsert narrowing and new delete_feature_request as the Postgres impl.
crates/temper-server/src/observe/mod_test.rs Adds five integration tests covering the pure-read invariant, idempotent materialization, concurrent retries, tenant-ambiguity safeguards, and cross-restart durability.

Reviews (3): Last reviewed commit: "fix(server): stabilize feature request c..." | Re-trigger Greptile

@nerdsane

Copy link
Copy Markdown
Owner Author

Independent review of the published open-PR diff at head 2b1caa48d3575da8c30a0b4e68998610c93b3c80.

Blocking findings:

  1. [P1] Existing GET-created records are never reconciled, so deployment preserves the duplicate user-visible state this change is intended to fix. In crates/temper-server/src/observe/evolution/operations.rs:154-215, materialization computes a new stable ID and only dispatches/upserts that ID. In operations.rs:327+, GET now lists the projection table as-is. Rows created by the old GET path have fresh/random IDs, so the first Sentinel run after upgrade inserts an additional stable row while every legacy row remains visible. Any human disposition or developer notes attached to a legacy row also remain stranded on that obsolete row; the conflict-update preservation in the store backends cannot help because the IDs differ. Please add an upgrade reconciliation/migration at the authorized command boundary (or an equivalent durable migration) that canonicalizes existing rows without losing human-owned fields, and add a test that seeds a legacy row before materialization and proves the resulting visible projection is singular and retains its review state.

  2. [P2] The concurrent-idempotency test does not prove that concurrent calls append only one creation event. In crates/temper-server/src/observe/mod_test.rs:1785-1885, both Sentinel calls complete, but first_event_count is merely captured after both calls and later compared with the sequential retry. If the concurrent pair appended two creation events, first_event_count == 2 and the test would still pass. Assert the exact expected creation-event count immediately after the joined calls (and retain the later restart/retry equality checks), so a race in durable idempotency cannot regress unnoticed.

Other audited properties in the diff look sound: the GET write loop is removed; identity includes tenant and explicit generator version; description and evidence ordering are normalized; the actor dispatch uses the stable entity ID and idempotency key; actor-first/projection-second ordering is retryable after a projection failure; and PostgreSQL/Turso preserve human-owned fields consistently on conflict.

Verdict: FAIL

@nerdsane

Copy link
Copy Markdown
Owner Author

Fresh re-review of the current published open-PR diff at head 7be908818b3d4fa566354d87bfc30840bee3cd33.

One blocking finding remains:

  1. [P1] Reconciliation does not preserve exact canonical developer-note bytes on partial-cleanup retry. In crates/temper-server/src/observe/evolution/operations/reconcile.rs:52-82, merged_review_state splits every note blob on blank lines, applies str::trim, inserts the fragments into a BTreeSet, and rejoins them in lexical order. Therefore an already-canonical note such as " Z \n\nA" is rewritten to "A\n\nZ" when a remaining legacy row triggers reconciliation. That loses leading/trailing whitespace and paragraph order even though the canonical row is supposed to survive a partial cleanup exactly. The regression test in crates/temper-server/src/observe/mod_test.rs:1980+ uses the already-normalized value "Report note A\n\nReport note B", so it cannot detect the byte rewrite. Preserve the canonical row note blob verbatim during merge/retry (and append/deduplicate legacy material without normalizing the canonical bytes), then cover reversed paragraph order and significant surrounding whitespace in the partial-cleanup test.

The prior concurrency finding is fixed: the test now counts CreateFeatureRequest transitions and requires exactly one after the joined requests. I also verified that cleanup is restricted to the legacy ID shape, stable digest IDs are never selected for deletion, reconciliation runs only inside the authorized Sentinel command, evidence matching includes the generated description discriminator used to separate actions with identical refs, and both store backends expose the same update/delete semantics.

Verdict: FAIL

@nerdsane

Copy link
Copy Markdown
Owner Author

Fresh re-review of the current published open-PR diff at head c5e3959eee127e1dda1bf9941a726710301ba1a4.

The canonical-note-byte finding is fixed. merged_review_state now retains the stable row note string verbatim, uses normalized components only for duplicate detection, and appends missing legacy components in deterministic BTreeSet order. The reversed-order/surrounding-whitespace regression proves the canonical bytes survive a partial-cleanup state. The earlier action-separation and exact-one-CreateFeatureRequest assertions also remain present and correct.

One new blocking finding from the whole-diff review:

  1. [P1] Legacy reconciliation is not tenant-isolated and can move or delete another tenant's review state. crates/temper-server/src/observe/evolution/operations.rs:161-174 loads every feature-request projection with list_feature_requests(None), then operations/reconcile.rs:22-35,109-126 matches legacy rows only by category, frequency, description prefix, and trajectory refs. The source tenant is neither passed to reconciliation nor stored/checked on the legacy row. If two tenants have equivalent evidence (including colliding timestamp refs), a Sentinel run for tenant A can merge tenant B's legacy disposition/notes into A's tenant-salted stable row and delete B's legacy projection. Concurrent runs can copy the same legacy review state into both stable rows before either delete completes. Salting the new stable ID does not protect this migration path. Reconcile only legacy rows whose source tenant can be established unambiguously; ambiguous tenantless legacy rows must not be guessed/deleted. Add a two-tenant regression with identical canonical evidence/refs and distinct review notes proving neither tenant can consume or delete the other's state.

Verdict: FAIL

@nerdsane

Copy link
Copy Markdown
Owner Author

Fresh re-review of the current published open-PR diff at head af103e9f2a91d99d3422754fb10f54c1547849d5.

No blocking findings.

Verified:

  • Legacy reconciliation now gates each legacy row through legacy_evidence_belongs_unambiguously_to_tenant: malformed or empty refs fail closed; every unique ref must resolve in the supplied evidence; all matching entries must identify exactly one source tenant; and that tenant must equal the active tenant. Ambiguous or missing ownership therefore leaves the tenantless legacy projection untouched.
  • The new two-tenant regression gives both tenants identical timestamp refs and distinct legacy notes, materializes each tenant independently, and proves both ambiguous legacy rows and their review state remain unchanged while each tenant receives a separate open canonical row.
  • Evidence matching still distinguishes different actions with identical refs through the generated-description discriminator, preventing cross-action review-state contamination.
  • Stable SHA-256 revision IDs cannot match the legacy ID shape and are never selected for cleanup.
  • Canonical stable-row note bytes remain verbatim, including reversed paragraph order and surrounding whitespace; missing legacy components are normalized only for duplicate detection, appended in deterministic order, and become idempotent components on retry.
  • Concurrent Sentinel calls are asserted to append exactly one CreateFeatureRequest transition, while restart and sequential retries retain the same durable entity/event count.
  • GET remains projection-only, stable identity remains tenant-salted/versioned/order-normalized, and PostgreSQL/Turso retain equivalent conflict-update and cleanup behavior.

Verdict: PASS

@nerdsane

Copy link
Copy Markdown
Owner Author

@greptile review

@nerdsane

Copy link
Copy Markdown
Owner Author

ARN-240 live local E2E (head af103e9f)

Built and ran the real CLI/server with an isolated Turso database. External telemetry was explicitly disabled:

$ CARGO_INCREMENTAL=0 cargo build -p temper-cli --bin temper
Finished `dev` profile [unoptimized + debuginfo]

$ LOGFIRE_TOKEN= OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_TRACES_ENDPOINT= OTEL_EXPORTER_OTLP_METRICS_ENDPOINT= OTEL_EXPORTER_OTLP_LOGS_ENDPOINT= HOME=/tmp/arn240-e2e-af103e9f RUST_LOG=temper_server=info target/debug/temper serve --port 43140 --no-observe
OTEL export disabled: no endpoint configured.
Storage: turso (file:/tmp/arn240-e2e-af103e9f/.local/share/temper/agents.db)
Listening on http://0.0.0.0:43140

Initial projection:

$ curl -sS -H 'X-Temper-Principal-Kind: admin' http://127.0.0.1:43140/observe/evolution/feature-requests
{"feature_requests":[],"total":0}

Submitted this request three times (sessions 1, 2, and 3); every response was HTTP/1.1 201 Created:

$ curl -sS -i -X POST -H 'Content-Type: application/json' -H 'X-Temper-Principal-Kind: admin' --data '{"action":"GenerateReport","intent":"Generate a report","tenant":"default","entity_type":"MissingCapability","error":"EntitySetNotFound: Report","source":"platform","agent_id":"agent-1","session_id":"session-1"}' http://127.0.0.1:43140/api/evolution/trajectories/unmet
HTTP/1.1 201 Created

GET remained a pure read even though qualifying evidence existed:

$ curl -sS -H 'X-Temper-Principal-Kind: admin' http://127.0.0.1:43140/observe/evolution/feature-requests
{"feature_requests":[],"total":0}

Authorized materialization, with the tenant explicit on this multi-tenant server:

$ curl -sS -X POST -H 'Content-Type: application/json' -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' --data '{}' http://127.0.0.1:43140/api/evolution/sentinel/check
..."feature_requests_count":1,"feature_request_ids":["FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9"]}

$ curl -sS -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' http://127.0.0.1:43140/observe/evolution/feature-requests
{"feature_requests":[{"id":"FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9",..."frequency":3,..."disposition":"Open","developer_notes":null,...}],"total":1}

Repeating Sentinel returned the same ID and kept total:1. Then I recorded human review state:

$ curl -sS -X PATCH -H 'Content-Type: application/json' -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' --data '{"disposition":"Planned","developer_notes":"Live E2E review"}' http://127.0.0.1:43140/observe/evolution/feature-requests/FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9
{"id":"FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9","updated":true}

$ curl -sS -X POST -H 'Content-Type: application/json' -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' --data '{}' http://127.0.0.1:43140/api/evolution/sentinel/check
..."feature_requests_count":1,"feature_request_ids":["FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9"]}

$ curl -sS -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' http://127.0.0.1:43140/observe/evolution/feature-requests
..."disposition":"Planned","developer_notes":"Live E2E review"..."total":1}

Stopped the server, restarted the exact same command/HOME/database, then repeated Sentinel and GET:

$ curl -sS -X POST -H 'Content-Type: application/json' -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' --data '{}' http://127.0.0.1:43140/api/evolution/sentinel/check
..."feature_requests_count":1,"feature_request_ids":["FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9"]}

$ curl -sS -H 'X-Temper-Principal-Kind: admin' -H 'X-Tenant-Id: default' http://127.0.0.1:43140/observe/evolution/feature-requests
{"feature_requests":[{"id":"FR-3124526acd4637730d1145eb8e03ae8cfab2ab284161d83269f2a539d1ec83c9",..."disposition":"Planned","developer_notes":"Live E2E review"...}],"total":1}

Result: stable ID, one projection, preserved human state, and durable restart idempotency all PASS. The RED regression remains isolated in commit 5338da03; the final feature-request suite is 8/8 PASS and asserts exactly one CreateFeatureRequest transition under concurrent Sentinel calls.

Comment thread crates/temper-server/src/observe/evolution/operations.rs
@nerdsane
nerdsane marked this pull request as ready for review July 14, 2026 12:46
@nerdsane

Copy link
Copy Markdown
Owner Author

ARENA SHIPPABLE · GPT-5.6 · 2026-07-14T12:46:34Z

Fresh GPT-5.6 whole-diff review PASS: #385 (comment)

Live local HTTP + durable restart E2E evidence: #385 (comment)

Greptile PASS and all required CI checks green on head af103e9. PR is ready for review. MERGE NOTHING.

@rita-aga

Copy link
Copy Markdown
Collaborator

Fresh independent review of the complete published open-PR diff at head 40f6471.

The latest Greptile remediation is correct: stable_platform_gap_category_key is exhaustive, preserves the established category strings, and removes Debug formatting from the durable identity preimage.

One actionable finding remains:

  1. [P1] Feature-request identity is still evidence-order dependent when a qualifying trajectory has no error text. In crates/temper-server/src/observe/evolution/insight_generator/gap_analysis.rs:182-190, the accumulator is initialized with entry.error.unwrap_or_default(), then later descriptions replace it only when a Some(error) value is lexicographically smaller. If the first entry has error = None, the baseline is the empty string and no later nonempty error can be smaller; if the exact same entries are replayed with a Some(error) entry first, the description remains nonempty. The sorted trajectory refs and tenant/version/category inputs are then identical, but the generated description and SHA-256 ID differ solely because input order changed. The current order-independence test at crates/temper-server/src/observe/mod_test.rs:1745-1797 covers only Some(error) entries, so it misses this case. Track the minimum as an Option (or otherwise exclude missing errors from the minimum) and add a reorder regression containing both None and Some error values that asserts identical description and stable ID.

I also rechecked GET purity, actor/projection retry ordering, exact-one concurrent creation, restart durability, legacy cleanup bounds, preservation of human disposition and canonical note bytes, cross-action separation, tenant-ambiguous fail-closed migration, backend symmetry, and DST-sensitive collection/time behavior; no additional blockers found.

Verdict: FAIL

@rita-aga

Copy link
Copy Markdown
Collaborator

Fresh independent review of the complete published open-PR diff at reviewed head 40f6471d5b5c371c029d22dcf8a64821613b09d0.

No blocking findings.

The premise of the prior FAIL comment is refuted by the complete generator path. In gap_analysis.rs, generate_feature_requests computes error_pattern = categorize_error(entry.error.as_deref()) and groups by (entry.action, error_pattern). In the remote head implementation of categorize_error, None maps to "Unknown"; every Some(...) maps to "EntitySetNotFound", "AuthzDenied", "ActionNotFound", "GuardRejected", or "Other", never "Unknown". Therefore a None entry and a Some(error) entry cannot enter the same PlatformGapAccum, so the cited empty-string initialization cannot make one evidence group order-dependent. A group of None entries always retains the empty suffix, while a group of Some entries selects the lexicographically smallest error through the new comparison; its timestamps are sorted before record construction. The proposed mixed None/Some reorder is thus not a permutation of one aggregation bucket and would test separate buckets.

The Greptile durable-category remediation is correct. stable_platform_gap_category_key exhaustively maps all four PlatformGapCategory variants to explicit established tokens, stable_feature_request_id uses that function in the SHA-256 preimage, and the exact-token regression covers every variant. Remaining Debug formatting is outside the durable identity preimage and does not recreate the reported orphan-ID risk.

I also reviewed the full ten-file PR diff for GET purity, tenant filtering and tenant-salted identity, fail-closed legacy tenant attribution, cross-action reconciliation, preservation of disposition and canonical note bytes, actor-first retry recovery, concurrent and restart idempotency, stable evidence ordering, bounded legacy cleanup, Postgres/Turso symmetry, and deterministic-simulation-sensitive collection/time usage. The focused tests cover pure repeated/concurrent GETs, exact-one creation under concurrent Sentinel calls, explicit revisions, legacy reconciliation, ambiguous cross-tenant evidence, category tokens, and process restart. Current GitHub checks, including Tests, Compile & Lint, DST/platform suites, integrity, verification, and Greptile, are green at the reviewed head.

Verdict: PASS

@rita-aga

Copy link
Copy Markdown
Collaborator

ARENA SHIPPABLE · GPT-5.6 · 2026-07-14T21:09:11Z

ARN-240 is shippable at 40f6471d5b5c371c029d22dcf8a64821613b09d0. MERGE NOTHING.

Required PR-gate evidence, in order:

Current-head validation is green: local pre-push cargo fmt --check, strict workspace clippy, readability ratchet, and cargo test --workspace all passed; GitHub Compile & Lint, Tests, all DST partitions, Integrity & DST Patterns, Verification Contract, Spec Verification, Instrumentation Hygiene, and the automatic current-head Greptile Review check all pass. The PR remains open and unmerged.

@rita-aga

Copy link
Copy Markdown
Collaborator

Closing this ARN-240 competition entry. Adjudicated in favor of Fable #402 (messy path (6 commits, 4 self-introduced defects) vs the clean winner). This branch is preserved for grafting its best parts into the winner; full scoring is on the ARN-165 arena board. (Rita-directed close, 2026-08-11.)

@rita-aga rita-aga closed this Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants