diff --git a/openspec/changes/direct-operation-receipt-lookup/.openspec.yaml b/openspec/changes/direct-operation-receipt-lookup/.openspec.yaml new file mode 100644 index 0000000..cbd245e --- /dev/null +++ b/openspec/changes/direct-operation-receipt-lookup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 diff --git a/openspec/changes/direct-operation-receipt-lookup/design.md b/openspec/changes/direct-operation-receipt-lookup/design.md new file mode 100644 index 0000000..c42766e --- /dev/null +++ b/openspec/changes/direct-operation-receipt-lookup/design.md @@ -0,0 +1,23 @@ +## Context + +Operation creation writes `memory_operation:`, while `get` and `get_db` issue `SELECT ... WHERE operation_id = $id LIMIT 1`. The uncomfortable fact is that the unique secondary index did not keep deployed receipt reads within the worker's ten-second request budget under heavy memory pressure; one lookup took 10.64 seconds before restart and 3.21 seconds after warmup. + +## Goals + +- Make receipt retrieval address the record that submission already created. +- Preserve receipt conversion, missing-record behavior, replay semantics, and conflicts. +- Avoid schema or migration changes. + +## Decision + +Use the Surreal client record selection API with table `memory_operation` and the existing `record_key(operation_id)` value in both `get` and `get_db`. The record key is SHA-256 hex, so it is deterministic and safe as a Surreal record identifier. The stored `operation_id` and its unique index remain available for validation and older queries. + +## Risks + +Rows created outside the operation service under a noncanonical record key will no longer be discoverable through the API. Such a row violates the service's existing creation contract; migrations and the operation submit path already use canonical keys. + +## Verification + +- Run the operations unit and integration tests, including duplicate submission, conflict, restart, and receipt lookup cases. +- Measure the deployed GET endpoint before and after installing the rebuilt server. +- Run the learning worker against the durable backlog and record delivered counts and timeouts. diff --git a/openspec/changes/direct-operation-receipt-lookup/proposal.md b/openspec/changes/direct-operation-receipt-lookup/proposal.md new file mode 100644 index 0000000..67bd9ae --- /dev/null +++ b/openspec/changes/direct-operation-receipt-lookup/proposal.md @@ -0,0 +1,23 @@ +## Why + +Operation receipts are stored under a deterministic record key derived from `operation_id`, but receipt reads filter the whole table by a duplicate field. On the deployed RocksDB service, a single lookup took 3–10 seconds and caused the durable learning worker to time out after only a few records. + +## What Changes + +- Read operation receipts and internal operation state by their deterministic record identity. +- Retain the existing `operation_id` field and unique index for contract validation and compatibility. +- Verify submit, replay, conflict, and receipt-read behavior through the operation integration suite. + +## Capabilities + +### New Capabilities + +- `operation-receipt-lookup`: Deterministic receipt retrieval by the durable operation identity. + +### Modified Capabilities + +None. + +## Impact + +This changes two read paths in `src/operations.rs`. The HTTP schema, stored record format, migrations, and operation state machine remain unchanged. diff --git a/openspec/changes/direct-operation-receipt-lookup/specs/operation-receipt-lookup/spec.md b/openspec/changes/direct-operation-receipt-lookup/specs/operation-receipt-lookup/spec.md new file mode 100644 index 0000000..2b8e3e7 --- /dev/null +++ b/openspec/changes/direct-operation-receipt-lookup/specs/operation-receipt-lookup/spec.md @@ -0,0 +1,27 @@ +## Purpose + +Ensure durable operation receipts can be retrieved by their stable identity without work proportional to unrelated ledger rows. + +## ADDED Requirements + +### Requirement: Receipt lookup uses durable operation identity +The ledger SHALL retrieve an operation by the deterministic record identity derived from the requested `operation_id`, and SHALL return only a record whose durable identity corresponds to that request. + +#### Scenario: Existing operation is requested +- **WHEN** a caller requests a receipt for an `operation_id` that was durably accepted +- **THEN** the ledger returns that operation's current receipt without filtering unrelated operation rows + +#### Scenario: Unknown operation is requested +- **WHEN** a caller requests an `operation_id` whose deterministic record does not exist +- **THEN** the ledger returns the existing not-found response + +### Requirement: Submit replay uses the same identity lookup +The ledger SHALL use the same deterministic receipt lookup after submission and when resolving a concurrent or replayed operation ID. + +#### Scenario: Existing ID is submitted again +- **WHEN** a caller submits an existing `operation_id` with the same payload hash +- **THEN** the ledger returns the authoritative existing receipt without creating a second operation + +#### Scenario: Existing ID has another payload hash +- **WHEN** a caller submits an existing `operation_id` with a different payload hash +- **THEN** the ledger returns the existing conflict response diff --git a/openspec/changes/direct-operation-receipt-lookup/tasks.md b/openspec/changes/direct-operation-receipt-lookup/tasks.md new file mode 100644 index 0000000..190939e --- /dev/null +++ b/openspec/changes/direct-operation-receipt-lookup/tasks.md @@ -0,0 +1,8 @@ +## 1. Deterministic lookup + +- [x] 1.1 Replace field-filtered receipt reads with deterministic record selection in both public and internal operation reads; verify the crate compiles + +## 2. Verification + +- [x] 2.1 Run the operations test targets and strict OpenSpec validation +- [ ] 2.2 Install the rebuilt server and measure receipt lookup plus learning-worker backlog progress diff --git a/src/operations.rs b/src/operations.rs index 63e0a8a..0ad4512 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -420,24 +420,17 @@ impl OperationService { pub async fn get(&self, operation_id: &str) -> Result> { let db = self.surreal()?.db()?; - let rows: Vec = db - .query("SELECT * FROM memory_operation WHERE operation_id = $id LIMIT 1") - .bind(("id", operation_id.to_owned())) - .await? - .check()? - .take(0)?; - rows.into_iter().next().map(TryInto::try_into).transpose() + let operation: Option = db + .select(("memory_operation", record_key(operation_id))) + .await?; + operation.map(TryInto::try_into).transpose() } async fn get_db(&self, operation_id: &str) -> Result> { let db = self.surreal()?.db()?; - let rows: Vec = db - .query("SELECT * FROM memory_operation WHERE operation_id = $id LIMIT 1") - .bind(("id", operation_id.to_owned())) - .await? - .check()? - .take(0)?; - Ok(rows.into_iter().next()) + Ok(db + .select(("memory_operation", record_key(operation_id))) + .await?) } async fn list_nonterminal_ids(&self) -> Result> {