Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,20 @@ pub(crate) fn generate_unmet_intents(

const FEATURE_REQUEST_THRESHOLD: u64 = 3;

/// Stable, content-derived feature-request id: the same platform gap always
/// maps to the same record (ARN-240). 12 hex chars of SHA-256 over the gap
/// group key, NUL-separated to prevent field-boundary ambiguity.
fn deterministic_feature_request_id(action: &str, error_pattern: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(action.as_bytes());
hasher.update([0]);
hasher.update(error_pattern.as_bytes());
let digest = hasher.finalize();
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
format!("FR-{}", &hex[..12])
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

pub(crate) fn generate_feature_requests(
entries: &[crate::state::TrajectoryEntry],
) -> Vec<FeatureRequestRecord> {
Expand Down Expand Up @@ -196,8 +210,14 @@ pub(crate) fn generate_feature_requests(
_ => PlatformGapCategory::MissingCapability,
};

let mut header = RecordHeader::new(RecordType::FeatureRequest, "insight-generator");
// ARN-240: identity derives from the gap group, not a minted UUID —
// the same (action, error pattern) always maps to the same record, so
// regeneration on every listing is idempotent by construction.
header.id = deterministic_feature_request_id(&accum.action, &accum.error_pattern);

feature_requests.push(FeatureRequestRecord {
header: RecordHeader::new(RecordType::FeatureRequest, "insight-generator"),
header,
category,
description: format!(
"Agents tried '{}' {} times — {}",
Expand Down
50 changes: 31 additions & 19 deletions crates/temper-server/src/observe/evolution/operations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,14 @@ use crate::state::{ObserveRefreshHint, ServerState};
mod materialize;
mod support;

#[cfg(test)]
mod feature_requests_test;

pub(crate) use materialize::{handle_evolution_analyze, handle_evolution_materialize};

use support::{
create_system_entity_logged, emit_refresh_hints, next_system_entity_id, persist_alerts,
persist_insights, spawn_intent_discovery,
create_system_entity_logged, emit_refresh_hints, persist_alerts, persist_insights,
spawn_intent_discovery,
};

/// POST /api/evolution/sentinel/check -- trigger sentinel rule evaluation.
Expand Down Expand Up @@ -242,7 +245,10 @@ pub(crate) async fn handle_feature_requests(
FeatureRequestDisposition::WontFix => "WontFix",
FeatureRequestDisposition::Resolved => "Resolved",
};
if let Err(error) = store
// ARN-240: the record id is content-derived, so this is a true
// upsert; the system entity is created ONCE, on the insert that
// first discovered the gap, and shares the record's id.
let inserted = match store
.upsert_feature_request(
&feature_request.header.id,
&format!("{:?}", feature_request.category),
Expand All @@ -254,23 +260,29 @@ pub(crate) async fn handle_feature_requests(
)
.await
{
tracing::warn!(error = %error, backend = store.backend_name(), "failed to upsert feature request");
}
Ok(inserted) => inserted,
Err(error) => {
tracing::warn!(error = %error, backend = store.backend_name(), "failed to upsert feature request");
false
}
};

create_system_entity_logged(
&state,
"FeatureRequest",
&next_system_entity_id("FR"),
"CreateFeatureRequest",
serde_json::json!({
"category": format!("{:?}", feature_request.category),
"description": feature_request.description,
"frequency": feature_request.frequency.to_string(),
"developer_notes": feature_request.developer_notes.clone().unwrap_or_default(),
"legacy_record_id": feature_request.header.id,
}),
)
.await;
if inserted {
create_system_entity_logged(
&state,
"FeatureRequest",
&feature_request.header.id,
"CreateFeatureRequest",
serde_json::json!({
"category": format!("{:?}", feature_request.category),
"description": feature_request.description,
"frequency": feature_request.frequency.to_string(),
"developer_notes": feature_request.developer_notes.clone().unwrap_or_default(),
"legacy_record_id": feature_request.header.id,
}),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
)
.await;
}
}

return match store.list_feature_requests(disposition_filter).await {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
//! ARN-240: GET /observe/evolution/feature-requests must be idempotent.
//!
//! The handler generates feature requests from trajectory gaps on every read.
//! Each generated record minted a fresh UUID-suffixed id, so the store
//! "upsert" inserted a NEW row per GET, and a fresh `FR-{uuid}` system entity
//! was dispatched per generated record per GET — reads spawned unbounded
//! duplicates, and re-generation clobbered developer-owned fields.

use std::collections::BTreeMap;

use crate::registry::SpecRegistry;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use temper_runtime::ActorSystem;

use crate::state::{ServerState, TrajectoryEntry, TrajectorySource};
use crate::storage::StorageStack;

fn failing_platform_entry(n: u64) -> TrajectoryEntry {
TrajectoryEntry {
timestamp: format!("2026-07-13T00:00:{n:02}Z"),
tenant: "arn240".to_string(),
entity_type: "Invoice".to_string(),
entity_id: format!("inv-{n}"),
action: "GenerateInvoice".to_string(),
success: false,
from_status: None,
to_status: None,
error: Some("EntitySetNotFound: Invoice".to_string()),
agent_id: Some("agent-1".to_string()),
session_id: None,
authz_denied: None,
denied_resource: None,
denied_module: None,
source: Some(TrajectorySource::Platform),
spec_governed: Some(false),
agent_type: None,
intent: None,
request_body: None,
matched_policy_ids: None,
}
}

const FEATURE_REQUEST_IOA: &str = r#"
[automaton]
name = "FeatureRequest"
states = ["New", "Ready"]
initial = "New"

[[state]]
name = "category"
type = "string"
initial = ""

[[state]]
name = "description"
type = "string"
initial = ""

[[state]]
name = "frequency"
type = "string"
initial = ""

[[state]]
name = "developer_notes"
type = "string"
initial = ""

[[state]]
name = "legacy_record_id"
type = "string"
initial = ""

[[action]]
name = "CreateFeatureRequest"
kind = "input"
from = ["New"]
to = "Ready"
params = ["category", "description", "frequency", "developer_notes", "legacy_record_id"]
hint = "Record a platform gap surfaced by the insight generator."
"#;

const FEATURE_REQUEST_CSDL: &str = r#"<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="Temper.System" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="FeatureRequest">
<Key><PropertyRef Name="Id"/></Key>
<Property Name="Id" Type="Edm.String" Nullable="false"/>
</EntityType>
<EntityContainer Name="Container">
<EntitySet Name="FeatureRequests" EntityType="Temper.System.FeatureRequest"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>"#;

fn registry_with_system_feature_request_spec() -> SpecRegistry {
let mut registry = SpecRegistry::new();
let csdl = temper_spec::parse_csdl(FEATURE_REQUEST_CSDL).expect("csdl parses");
registry.register_tenant(
"temper-system",
csdl,
FEATURE_REQUEST_CSDL.to_string(),
&[("FeatureRequest", FEATURE_REQUEST_IOA)],
);
registry
}

async fn state_with_gap_trajectories() -> (ServerState, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let db_url = format!("file:{}", dir.path().join("arn240.db").display());
let turso = temper_store_turso::TursoEventStore::new(&db_url, None)
.await
.expect("turso store");
let stack = StorageStack::from_turso(turso);

// Three failing Platform-source entries for the same (action, error
// pattern) — exactly the FEATURE_REQUEST_THRESHOLD gap group.
let sink = stack.trajectory.clone().expect("trajectory sink");
for n in 0..3 {
sink.persist_trajectory_entry(&failing_platform_entry(n))
.await
.expect("persist trajectory");
}

let system = ActorSystem::new("arn240-test");
let mut state = ServerState::from_registry(system, registry_with_system_feature_request_spec());
state.set_storage_stack(stack);
(state, dir)
}

fn system_headers() -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert("x-temper-principal-kind", "system".parse().expect("hdr"));
headers
}

async fn get_feature_requests(state: &ServerState) -> serde_json::Value {
let response = super::handle_feature_requests(
State(state.clone()),
system_headers(),
Query(BTreeMap::new()),
)
.await
.expect("GET feature-requests");
response.0
}

/// A read must not create anything new on re-read: the same gap group must
/// map to the same feature request, however many times it is listed.
#[tokio::test]
async fn repeated_get_does_not_duplicate_feature_requests() {
let (state, _dir) = state_with_gap_trajectories().await;

let first = get_feature_requests(&state).await;
assert_eq!(
first["total"], 1,
"one gap group must yield one feature request, got: {first}"
);

let second = get_feature_requests(&state).await;
assert_eq!(
second["total"], 1,
"a GET is a read — re-reading must not create a duplicate feature \
request for the same gap group, got: {second}"
);
assert_eq!(
second["feature_requests"][0]["id"], first["feature_requests"][0]["id"],
"the same gap group must keep the same identity across reads"
);
}

/// Re-generation must not clobber developer-owned fields: a disposition set
/// via PATCH survives subsequent GETs while agents keep hitting the same gap.
#[tokio::test]
async fn get_preserves_developer_disposition_and_notes() {
let (state, _dir) = state_with_gap_trajectories().await;

let first = get_feature_requests(&state).await;
let id = first["feature_requests"][0]["id"]
.as_str()
.expect("feature request id")
.to_string();

let store = state.platform_metadata_store().expect("platform store");
store
.update_feature_request(&id, "WontFix", Some("duplicate of FR-1"))
.await
.expect("developer updates disposition");

let after = get_feature_requests(&state).await;
assert_eq!(after["total"], 1, "still exactly one row, got: {after}");
assert_eq!(
after["feature_requests"][0]["disposition"], "WontFix",
"a GET must not reset a developer's disposition, got: {after}"
);
assert_eq!(
after["feature_requests"][0]["developer_notes"], "duplicate of FR-1",
"a GET must not wipe developer notes, got: {after}"
);
}

/// The system entity behind a feature request is created ONCE — the entity
/// journal for the record's deterministic id holds exactly one creation
/// event however many times the listing runs. (Previously every GET
/// dispatched a fresh `FR-{uuid}` entity per generated record.)
#[tokio::test]
async fn repeated_get_creates_the_system_entity_exactly_once() {
let (state, _dir) = state_with_gap_trajectories().await;

let first = get_feature_requests(&state).await;
let id = first["feature_requests"][0]["id"]
.as_str()
.expect("feature request id")
.to_string();

let journal = |from: u64| {
let events = state.storage_stack.as_ref().expect("stack").events.clone();
let persistence_id = format!("temper-system:FeatureRequest:{id}");
async move {
events
.read_events(&persistence_id, from)
.await
.expect("read entity journal")
}
};

// The first GET must journal the entity UNDER THE RECORD'S ID (a fresh
// per-read id would leave this journal empty). A new entity journals a
// bootstrap Created event plus the action event, so assert non-empty
// rather than a count coupled to that implementation detail.
let after_first = journal(0).await;
assert!(
!after_first.is_empty(),
"the first GET must create the system entity under the record's id"
);

let second = get_feature_requests(&state).await;
assert_eq!(
second["feature_requests"][0]["id"].as_str(),
Some(id.as_str()),
"identity must be stable before comparing journals"
);

let after_second = journal(0).await;
assert_eq!(
after_second.len(),
after_first.len(),
"the second GET must not journal anything — the entity is created \
exactly once, on the read that first discovered the gap"
);
}
Loading
Loading