Skip to content

Commit 80415d1

Browse files
committed
feat(api): extend mutation replay through gateway interceptors
Add typed durable replay receipts for 24 ordinary unary mutations, protect sensitive payload fingerprints, and revalidate intercepted retries without repeating post-commit observation. Part of #3051 (phase 3b). Signed-off-by: Mrunal Patel <[email protected]>
1 parent 5dc3c72 commit 80415d1

23 files changed

Lines changed: 3077 additions & 173 deletions

File tree

‎architecture/gateway.md‎

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,8 @@ version when available. `google.rpc.RetryInfo` expresses a minimum retry delay;
6060
it does not establish that a mutation is safe to repeat. SDKs retain the original
6161
transport status, metadata, and unknown details alongside decoded fields.
6262

63-
Workspace lifecycle, membership, and sandbox-template mutations explicitly opt
64-
into durable request admission when the client supplies a UUID. Typed adapters
63+
Ordinary user-callable unary mutations explicitly opt into durable request
64+
admission when the client supplies a UUID. Typed adapters
6565
check current authorization before looking up a caller/method/workspace-scoped
6666
key. The payload fingerprint excludes that UUID and canonicalizes protobuf maps.
6767
An atomic, quota-checked insert chooses one executor; owned execution survives
@@ -71,10 +71,24 @@ interruption leave permanent unresolved claims, never stealable leases.
7171
Admission rows live outside user workspace namespaces and are bounded per caller.
7272
Successes expire after 24 hours; cleanup uses the unique admission incarnation
7373
and version so an old cleaner cannot delete a new attempt. Replay stores only
74-
resource UUID/version references or deletion outcomes. It checks the original
75-
workspace identity and current authorization, and never substitutes a same-name
76-
resource. Intercepted mutations and credential capabilities require separate
77-
adapters; this mechanism does not replay streams or repeat interceptor observers.
74+
resource references and reviewed public scalar/diagnostic receipts, never
75+
credential-bearing response snapshots. It checks original identities and current
76+
authorization and never substitutes a same-name resource. Sandbox responses are
77+
live projections of the original UUID; normal status reconciliation does not
78+
invalidate replay. Refresh status additionally requires the original grant epoch.
79+
Other resource projections retain exact-version guards. Terminal delete receipts
80+
do not require the deleted target or parent to remain present.
81+
82+
Sandbox, service, provider/profile, and policy/config adapters use keyed payload
83+
fingerprints derived from existing gateway JWT or primary TLS private material.
84+
Replicas must share that material; missing keys or key changes fail closed without
85+
changing admission identity. Workspace/template adapters retain their original
86+
format. Intercepted requests carry the original decoded payload only in a private
87+
in-memory extension. Replay reauthorizes original and current effective scopes,
88+
requires the same effective payload, and reruns current interceptor validation.
89+
Interceptors cannot mutate the request UUID. Server-marked replay suppresses
90+
post-commit observation, which remains best-effort rather than an outbox.
91+
Credential capabilities and streaming execution require separate contracts.
7892

7993
The gateway listens on one service port and multiplexes gRPC and HTTP traffic.
8094
The default local single-user deployment mode is mTLS user authentication:
@@ -366,7 +380,7 @@ Compute-driver, credential-driver, gateway-interceptor, and
366380
supervisor-middleware services are compiled contracts for internal extension
367381
boundaries, not public gateway RPCs. The current public inventory has 74
368382
methods, 278 messages, and 13 enums
369-
(`8fab4ae6475cc3b768db710c1fc4c0f2ed75000682749e303388ba908dda5b59`).
383+
(`5de04dd390599ebd165111df4f14c6dd05ae77aa748866a0e3ec1a22abd63133`).
370384
The removed `NetworkBinary.harness` field remains reserved by number and name,
371385
so protobuf implementations cannot reuse its wire slot or source identifier.
372386
The durable-policy compatibility decoder reads the former boolean before Prost
@@ -406,8 +420,8 @@ Missing targets return `NOT_FOUND` unless `allow_missing` explicitly requests
406420
failures remain errors. Already-revoked sessions complete without another write
407421
after current authorization. The removed response booleans are reserved by name
408422
and number; this coordinated pre-1.0 API change does not alter durable schemas.
409-
The outcome alone does not provide request deduplication. The six opted-in
410-
workspace/template methods require a request UUID for the admission contract.
423+
The outcome alone does not provide request deduplication. Opted-in unary methods
424+
require a request UUID for the admission contract.
411425

412426
| Dual-purpose encoded root | Current decision |
413427
|---|---|
@@ -460,8 +474,8 @@ advisor drafts without creating resource-specific tables.
460474

461475
Mutation admission uses a private, version-tagged JSON envelope in the same
462476
object store. Its identity namespace stays stable across format changes, and an
463-
unknown format fails closed. It contains no public response payloads and is not
464-
part of the protobuf storage closure.
477+
unknown format fails closed. It contains explicit typed receipts, not arbitrary
478+
public response payloads, and is not part of the protobuf storage closure.
465479

466480
Each sandbox policy revision stores the complete provenance annotation map
467481
supplied with that update. The revision payload is the authoritative immutable

‎crates/openshell-cli/src/commands/provider.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ pub async fn sandbox_provider_attach(
103103

104104
let response = match client
105105
.attach_sandbox_provider(AttachSandboxProviderRequest {
106+
request_id: String::new(),
106107
sandbox_name: name.to_string(),
107108
provider_name: provider.to_string(),
108109
expected_resource_version: resource_version,
@@ -159,6 +160,7 @@ pub async fn sandbox_provider_detach(
159160

160161
let response = match client
161162
.detach_sandbox_provider(DetachSandboxProviderRequest {
163+
request_id: String::new(),
162164
sandbox_name: name.to_string(),
163165
provider_name: provider.to_string(),
164166
expected_resource_version: resource_version,
@@ -471,6 +473,7 @@ async fn auto_create_provider(
471473
if let Some(exact_name) = preferred_name {
472474
// Explicit name: create with exactly that name, no retries.
473475
let request = CreateProviderRequest {
476+
request_id: String::new(),
474477
provider: Some(Provider {
475478
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
476479
id: String::new(),
@@ -519,6 +522,7 @@ async fn auto_create_provider(
519522
};
520523

521524
let request = CreateProviderRequest {
525+
request_id: String::new(),
522526
provider: Some(Provider {
523527
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
524528
id: String::new(),
@@ -670,6 +674,7 @@ async fn rollback_provider_create_after_gcloud_adc_failure(
670674
) -> Result<()> {
671675
match client
672676
.delete_provider(DeleteProviderRequest {
677+
request_id: String::new(),
673678
allow_missing: true,
674679
name: provider_name.to_string(),
675680
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -1101,6 +1106,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
11011106

11021107
let response = client
11031108
.create_provider(CreateProviderRequest {
1109+
request_id: String::new(),
11041110
provider: Some(Provider {
11051111
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
11061112
id: String::new(),
@@ -1140,6 +1146,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
11401146

11411147
if let Err(configure_err) = client
11421148
.configure_provider_refresh(ConfigureProviderRefreshRequest {
1149+
request_id: String::new(),
11431150
provider: provider_name.clone(),
11441151
credential_key: adc_credential_key.clone(),
11451152
strategy: ProviderCredentialRefreshStrategy::Oauth2RefreshToken as i32,
@@ -1165,6 +1172,7 @@ pub async fn provider_create_with_options(options: ProviderCreateOptions<'_>) ->
11651172

11661173
if let Err(rotate_err) = client
11671174
.rotate_provider_credential(RotateProviderCredentialRequest {
1175+
request_id: String::new(),
11681176
provider: provider_name.clone(),
11691177
credential_key: adc_credential_key,
11701178
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -1581,6 +1589,7 @@ pub async fn provider_profile_import(
15811589
if !items.is_empty() {
15821590
let response = client
15831591
.import_provider_profiles(ImportProviderProfilesRequest {
1592+
request_id: String::new(),
15841593
profiles: items,
15851594
workspace: workspace.to_string(),
15861595
})
@@ -1630,6 +1639,7 @@ pub async fn provider_profile_update(
16301639
.map_or(0, |profile| profile.resource_version);
16311640
let response = client
16321641
.update_provider_profiles(UpdateProviderProfilesRequest {
1642+
request_id: String::new(),
16331643
profile: Some(item),
16341644
expected_resource_version,
16351645
id: id.to_string(),
@@ -1694,6 +1704,7 @@ pub async fn provider_profile_delete(
16941704
for id in ids {
16951705
let response = match client
16961706
.delete_provider_profile(DeleteProviderProfileRequest {
1707+
request_id: String::new(),
16971708
allow_missing: true,
16981709
id: id.clone(),
16991710
workspace: workspace.to_string(),
@@ -1806,6 +1817,7 @@ pub async fn provider_refresh_config(
18061817
let mut client = grpc_client(server, tls).await?;
18071818
let status = client
18081819
.configure_provider_refresh(ConfigureProviderRefreshRequest {
1820+
request_id: String::new(),
18091821
provider: input.name.to_string(),
18101822
credential_key: input.credential_key.to_string(),
18111823
strategy: strategy as i32,
@@ -1839,6 +1851,7 @@ pub async fn provider_rotate(
18391851
let mut client = grpc_client(server, tls).await?;
18401852
let status = client
18411853
.rotate_provider_credential(RotateProviderCredentialRequest {
1854+
request_id: String::new(),
18421855
provider: name.to_string(),
18431856
credential_key: credential_key.to_string(),
18441857
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -1876,6 +1889,7 @@ pub async fn provider_refresh_delete(
18761889
let mut client = grpc_client(server, tls).await?;
18771890
let response = client
18781891
.delete_provider_refresh(DeleteProviderRefreshRequest {
1892+
request_id: String::new(),
18791893
allow_missing: true,
18801894
provider: name.to_string(),
18811895
credential_key: credential_key.to_string(),
@@ -2313,6 +2327,7 @@ pub async fn provider_update(options: ProviderUpdateOptions<'_>) -> Result<()> {
23132327

23142328
let response = client
23152329
.update_provider(UpdateProviderRequest {
2330+
request_id: String::new(),
23162331
provider: Some(Provider {
23172332
metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta {
23182333
id: String::new(),
@@ -2367,6 +2382,7 @@ pub async fn provider_delete(
23672382
for name in names {
23682383
let response = match client
23692384
.delete_provider(DeleteProviderRequest {
2385+
request_id: String::new(),
23702386
allow_missing: true,
23712387
name: name.clone(),
23722388
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),

‎crates/openshell-cli/src/run.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,7 @@ pub async fn sandbox_create(
626626
)])
627627
};
628628
let request = CreateSandboxRequest {
629+
request_id: String::new(),
629630
spec: Some(SandboxSpec {
630631
resource_requirements,
631632
environment: if template.is_none() {
@@ -3105,6 +3106,7 @@ pub async fn sandbox_delete(
31053106

31063107
let response = match client
31073108
.delete_sandbox(DeleteSandboxRequest {
3109+
request_id: String::new(),
31083110
allow_missing: true,
31093111
name: name.clone(),
31103112
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -3165,6 +3167,7 @@ pub async fn sandbox_stop(
31653167
let mut client = grpc_client(server, tls).await?;
31663168
let sandbox = client
31673169
.stop_sandbox(StopSandboxRequest {
3170+
request_id: String::new(),
31683171
name: name.to_string(),
31693172
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
31703173
})
@@ -3188,6 +3191,7 @@ pub async fn sandbox_start(
31883191
let mut client = grpc_client(server, tls).await?;
31893192
let sandbox = client
31903193
.start_sandbox(StartSandboxRequest {
3194+
request_id: String::new(),
31913195
name: name.to_string(),
31923196
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
31933197
})
@@ -3286,6 +3290,7 @@ pub async fn service_expose(
32863290
let mut client = grpc_client(server, tls).await?;
32873291
let response = client
32883292
.expose_service(ExposeServiceRequest {
3293+
request_id: String::new(),
32893294
sandbox: sandbox.to_string(),
32903295
service: service.to_string(),
32913296
target_port: u32::from(target_port),
@@ -3412,6 +3417,7 @@ pub async fn service_delete(
34123417
let mut client = grpc_client(server, tls).await?;
34133418
let response = client
34143419
.delete_service(DeleteServiceRequest {
3420+
request_id: String::new(),
34153421
allow_missing: false,
34163422
sandbox: sandbox.to_string(),
34173423
service: service.to_string(),
@@ -5668,6 +5674,7 @@ pub async fn sandbox_draft_approve(
56685674

56695675
let response = client
56705676
.approve_draft_chunk(ApproveDraftChunkRequest {
5677+
request_id: String::new(),
56715678
name: name.to_string(),
56725679
chunk_id: chunk_id.to_string(),
56735680
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -5700,6 +5707,7 @@ pub async fn sandbox_draft_reject(
57005707

57015708
client
57025709
.reject_draft_chunk(RejectDraftChunkRequest {
5710+
request_id: String::new(),
57035711
name: name.to_string(),
57045712
chunk_id: chunk_id.to_string(),
57055713
reason: reason.to_string(),
@@ -5741,6 +5749,7 @@ pub async fn sandbox_draft_approve_all(
57415749

57425750
let response = client
57435751
.approve_all_draft_chunks(ApproveAllDraftChunksRequest {
5752+
request_id: String::new(),
57445753
name: name.to_string(),
57455754
include_security_flagged,
57465755
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
@@ -5772,6 +5781,7 @@ pub async fn sandbox_draft_clear(
57725781

57735782
let response = client
57745783
.clear_draft_chunks(ClearDraftChunksRequest {
5784+
request_id: String::new(),
57755785
name: name.to_string(),
57765786
workspace_scope: Some(openshell_core::proto::workspace_selector(workspace)),
57775787
})

‎crates/openshell-gateway-interceptors/src/proto_json.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,7 @@ mod tests {
308308
let codec =
309309
ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
310310
let request = CreateSandboxRequest {
311+
request_id: String::new(),
311312
spec: Some(SandboxSpec {
312313
providers: vec!["github".to_string()],
313314
..SandboxSpec::default()
@@ -336,6 +337,7 @@ mod tests {
336337
fn interceptor_view_omits_nested_secrets_but_keeps_non_secret_fields() {
337338
let codec = ProtoJsonCodec::openshell().unwrap();
338339
let request = CreateProviderRequest {
340+
request_id: String::new(),
339341
provider: Some(Provider {
340342
r#type: "github".to_string(),
341343
credentials: HashMap::from([(
@@ -396,6 +398,7 @@ mod tests {
396398
fn generic_sandbox_environment_remains_visible() {
397399
let codec = ProtoJsonCodec::openshell().unwrap();
398400
let request = CreateSandboxRequest {
401+
request_id: String::new(),
399402
spec: Some(SandboxSpec {
400403
environment: HashMap::from([("FEATURE_FLAG".to_string(), "on".to_string())]),
401404
..SandboxSpec::default()

‎crates/openshell-gateway-interceptors/src/runtime.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,6 +755,7 @@ mod tests {
755755

756756
fn create_provider_operation(codec: &ProtoJsonCodec) -> ValidatedOperation {
757757
let request = CreateProviderRequest {
758+
request_id: String::new(),
758759
provider: Some(Provider {
759760
r#type: "github".to_string(),
760761
credentials: HashMap::from([(
@@ -1009,6 +1010,7 @@ mod tests {
10091010
codec: codec.clone(),
10101011
};
10111012
let request = UpdateConfigRequest {
1013+
request_id: String::new(),
10121014
name: "demo".to_string(),
10131015
expected_resource_version: u64::MAX - 1,
10141016
annotations: HashMap::from([
@@ -1049,6 +1051,7 @@ mod tests {
10491051
let codec =
10501052
ProtoJsonCodec::from_descriptor_set(openshell_core::FILE_DESCRIPTOR_SET).unwrap();
10511053
let request = CreateSandboxRequest {
1054+
request_id: String::new(),
10521055
spec: Some(SandboxSpec {
10531056
template: Some(SandboxTemplate {
10541057
resources: Some(

‎crates/openshell-sdk/src/client.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,7 @@ impl OpenShellClient {
356356
let response = self
357357
.unary(|mut grpc| {
358358
let request = proto::DeleteSandboxRequest {
359+
request_id: String::new(),
359360
allow_missing: opts.allow_missing,
360361
name: name.to_string(),
361362
workspace_scope: Some(proto::workspace_selector("default")),
@@ -374,6 +375,7 @@ impl OpenShellClient {
374375
let response = self
375376
.unary(|mut grpc| {
376377
let request = proto::StopSandboxRequest {
378+
request_id: String::new(),
377379
name: name.to_string(),
378380
workspace_scope: Some(proto::workspace_selector("default")),
379381
};
@@ -388,6 +390,7 @@ impl OpenShellClient {
388390
let response = self
389391
.unary(|mut grpc| {
390392
let request = proto::StartSandboxRequest {
393+
request_id: String::new(),
391394
name: name.to_string(),
392395
workspace_scope: Some(proto::workspace_selector("default")),
393396
};
@@ -938,6 +941,7 @@ impl WorkspaceScopedClient {
938941
.client
939942
.unary(|mut grpc| {
940943
let request = proto::DeleteSandboxRequest {
944+
request_id: String::new(),
941945
allow_missing: opts.allow_missing,
942946
name: name.to_string(),
943947
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
@@ -957,6 +961,7 @@ impl WorkspaceScopedClient {
957961
.client
958962
.unary(|mut grpc| {
959963
let request = proto::StopSandboxRequest {
964+
request_id: String::new(),
960965
name: name.to_string(),
961966
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
962967
};
@@ -972,6 +977,7 @@ impl WorkspaceScopedClient {
972977
.client
973978
.unary(|mut grpc| {
974979
let request = proto::StartSandboxRequest {
980+
request_id: String::new(),
975981
name: name.to_string(),
976982
workspace_scope: Some(proto::workspace_selector(&self.workspace)),
977983
};
@@ -1155,6 +1161,7 @@ fn create_sandbox_request(spec: SandboxSpec) -> proto::CreateSandboxRequest {
11551161
gpu: Some(proto::GpuResourceRequirements { count: None }),
11561162
});
11571163
proto::CreateSandboxRequest {
1164+
request_id: String::new(),
11581165
spec: Some(proto::SandboxSpec {
11591166
environment,
11601167
template,
@@ -1186,6 +1193,7 @@ fn create_sandbox_from_template_request(
11861193
policy,
11871194
} = spec;
11881195
proto::CreateSandboxRequest {
1196+
request_id: String::new(),
11891197
spec: Some(proto::SandboxSpec {
11901198
providers,
11911199
command,

0 commit comments

Comments
 (0)