{KIND_WORDS[alert.attention_kind] ?? 'Alert'} Β· from {from}
{step ? ` Β· step ${step}` : ''} Β· waited {ago(DateTime.formatIso(alert.requested_at), now)}
diff --git a/clients/swift/FractalClient/Sources/FractalClient/Client.swift b/clients/swift/FractalClient/Sources/FractalClient/Client.swift
index 893a518c303..df611aa1dbb 100644
--- a/clients/swift/FractalClient/Sources/FractalClient/Client.swift
+++ b/clients/swift/FractalClient/Sources/FractalClient/Client.swift
@@ -436,6 +436,7 @@ public actor FractalClient {
/// Issue one expiring foreground command, without durable receipt or replay.
public func deviceCommand(_ command: DeviceCommandRequest) async throws -> Envelope
{ try await post("v1/client/device-commands", command) }
public func deviceResult(_ result: DeviceResult) async throws -> Envelope { try await post("v1/client/device-results", result) }
+ public func clientTestRevoke(device: String) async throws -> JSONValue { try await post("v1/client/test-clients/revoke", ["device": device]) }
public func beginPairing(deviceName: String, personID: String) async throws -> Envelope { try await post("v1/client/pairings", PersonPairingBegin(apiVersion: fractalClientAPIVersion, deviceName: deviceName, personID: personID)) }
public func beginPairing(_ request: PairingBegin) async throws -> Envelope { try await post("v1/client/pairings", request) }
diff --git a/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift b/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift
new file mode 100644
index 00000000000..4f6ed189984
--- /dev/null
+++ b/clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift
@@ -0,0 +1,120 @@
+// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT.
+// Source: crates/fractal-client-codegen/templates/ClientControl.swift.in.
+import Foundation
+import CryptoKit
+
+public let clientResultPurpose = "client.device-result.v1"
+public let clientResultSignedFields = ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state_sha256"]
+public enum ClientControlRefusal: String, Codable, Sendable {
+ case deviceNotTestClient = "device-not-test-client", grantMissing = "grant-missing", grantExpired = "grant-expired"
+ case screenNotGranted = "screen-not-granted", verbNotGranted = "verb-not-granted", budgetExhausted = "budget-exhausted", actorNotGranted = "actor-not-granted"
+}
+public struct ClientTestEnrollment: Codable, Sendable {
+ public var device: String; public var kind: String; public var enrolledBy: String
+ public var consentRef: String; public var packetDigest: String; public var host: String; public var instance: String
+ public var pairingSubject: String; public var publicKey: String; public var session: String
+}
+public struct ClientTestRevocation: Codable, Sendable {
+ public var device: String; public var enrollmentId: String; public var revokedBy: String
+}
+public struct ClientCommandReceipt: Codable, Sendable {
+ public var commandId: String; public var terminalState: DeviceCommandOutcome
+ public var issuer: String; public var device: String; public var session: String?
+ public var grantId: String?; public var runId: String?; public var commandKind: String
+ public var screen: String?; public var reason: ClientControlRefusal?
+ public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding?
+ private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, screen, reason, verifiedResultSignature, signedResult }
+ public func encode(to encoder: Encoder) throws {
+ var box = encoder.container(keyedBy: CodingKeys.self)
+ try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState)
+ try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encodeIfPresent(session, forKey: .session)
+ try box.encodeIfPresent(grantId, forKey: .grantId); try box.encodeIfPresent(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind)
+ try box.encodeIfPresent(screen, forKey: .screen); try box.encodeIfPresent(reason, forKey: .reason)
+ if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) }
+ else { try box.encodeNil(forKey: .verifiedResultSignature) }
+ if let signedResult { try box.encode(signedResult, forKey: .signedResult) }
+ else { try box.encodeNil(forKey: .signedResult) }
+ }
+}
+public struct ClientVisibleItem: Codable, Sendable, Equatable {
+ public var id: String; public var title: String; public var body: String
+ public init(id: String, title: String, body: String) { self.id = id; self.title = title; self.body = body }
+}
+public struct ClientVisibleState: Codable, Sendable, Equatable {
+ public var route: String; public var screen: String; public var visible_item_ids: [String]; public var opened_item: ClientVisibleItem?
+ public init(route: String, screen: String, visible_item_ids: [String], opened_item: ClientVisibleItem?) {
+ self.route = route; self.screen = screen; self.visible_item_ids = visible_item_ids; self.opened_item = opened_item
+ }
+ private enum CodingKeys: String, CodingKey { case route, screen, visible_item_ids, opened_item }
+ public func encode(to encoder: Encoder) throws {
+ var box = encoder.container(keyedBy: CodingKeys.self)
+ try box.encode(route, forKey: .route); try box.encode(screen, forKey: .screen)
+ try box.encode(visible_item_ids, forKey: .visible_item_ids)
+ if let opened_item { try box.encode(opened_item, forKey: .opened_item) }
+ else { try box.encodeNil(forKey: .opened_item) }
+ }
+}
+public struct ClientResultBinding: Codable, Sendable, Equatable {
+ public var purpose: String; public var command_kind: String; public var device: String; public var session: String
+ public var grant_id: String; public var run_id: String; public var command_id: String; public var outcome: String
+ public var error_code: String?; public var visible_state: ClientVisibleState
+ public init(purpose: String = clientResultPurpose, command_kind: String, device: String, session: String, grant_id: String, run_id: String, command_id: String, outcome: String, error_code: String?, visible_state: ClientVisibleState) {
+ self.purpose = purpose; self.command_kind = command_kind; self.device = device; self.session = session
+ self.grant_id = grant_id; self.run_id = run_id; self.command_id = command_id; self.outcome = outcome
+ self.error_code = error_code; self.visible_state = visible_state
+ }
+ private enum CodingKeys: String, CodingKey {
+ case purpose, command_kind, device, session, grant_id, run_id, command_id, outcome, error_code, visible_state
+ }
+ public func encode(to encoder: Encoder) throws {
+ var box = encoder.container(keyedBy: CodingKeys.self)
+ try box.encode(purpose, forKey: .purpose); try box.encode(command_kind, forKey: .command_kind)
+ try box.encode(device, forKey: .device); try box.encode(session, forKey: .session)
+ try box.encode(grant_id, forKey: .grant_id); try box.encode(run_id, forKey: .run_id)
+ try box.encode(command_id, forKey: .command_id); try box.encode(outcome, forKey: .outcome)
+ if let error_code { try box.encode(error_code, forKey: .error_code) }
+ else { try box.encodeNil(forKey: .error_code) }
+ try box.encode(visible_state, forKey: .visible_state)
+ }
+}
+public struct ClientControlGrant: Codable, Sendable, Equatable {
+ public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String
+ public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String
+ public var item_id: String?
+}
+private func clientResultQuote(_ text: String) -> String {
+ var result = "\""
+ for scalar in text.unicodeScalars {
+ switch scalar.value {
+ case 0x22: result += "\\\""
+ case 0x5c: result += "\\\\"
+ case 8: result += "\\b"
+ case 9: result += "\\t"
+ case 10: result += "\\n"
+ case 12: result += "\\f"
+ case 13: result += "\\r"
+ case 0...31: result += String(format: "\\u%04x", scalar.value)
+ default: result.unicodeScalars.append(scalar)
+ }
+ }
+ return result + "\""
+}
+/// RFC 8785 JCS for the generated closed string-only visible-state schema.
+public func visibleStateCanonicalJson(_ state: ClientVisibleState) -> String {
+ let item = state.opened_item.map { "{\"body\":" + clientResultQuote($0.body) + ",\"id\":" + clientResultQuote($0.id) + ",\"title\":" + clientResultQuote($0.title) + "}" } ?? "null"
+ return "{\"opened_item\":" + item + ",\"route\":" + clientResultQuote(state.route) + ",\"screen\":" + clientResultQuote(state.screen) + ",\"visible_item_ids\":[" + state.visible_item_ids.map(clientResultQuote).joined(separator: ",") + "]}"
+}
+public func visibleStateDigest(_ state: ClientVisibleState) -> String {
+ SHA256.hash(data: Data(visibleStateCanonicalJson(state).utf8)).map { String(format: "%02x", $0) }.joined()
+}
+/// Sign with P256.Signing.PrivateKey.signature(for:).rawRepresentation (r||s).
+/// This domain is never message.send authorization. Enforce active grant first.
+public func clientResultCanonicalBytes(_ binding: ClientResultBinding) -> Data {
+ let fields: [(String, String?)] = [
+ ("purpose", binding.purpose), ("command_kind", binding.command_kind), ("device", binding.device),
+ ("session", binding.session), ("grant_id", binding.grant_id), ("run_id", binding.run_id),
+ ("command_id", binding.command_id), ("outcome", binding.outcome), ("error_code", binding.error_code),
+ ("visible_state_sha256", visibleStateDigest(binding.visible_state))
+ ]
+ return Data((["smallclaims-claim-fields-v1"] + fields.map { $0.0 + "=" + ($0.1.map(clientResultQuote) ?? "null") }).joined(separator: "\n").utf8)
+}
diff --git a/clients/swift/FractalClient/Sources/FractalClient/Models.swift b/clients/swift/FractalClient/Sources/FractalClient/Models.swift
index 4b82447846e..66785dff35f 100644
--- a/clients/swift/FractalClient/Sources/FractalClient/Models.swift
+++ b/clients/swift/FractalClient/Sources/FractalClient/Models.swift
@@ -791,20 +791,23 @@ public struct DeviceCommandRequest: Codable, Sendable {
public let apiVersion, device: String
public let command: PhoneCommand
public let ttlMS: UInt64?
- public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, apiVersion: String = "st3.client.v0") {
- self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS
+ public let grantID: String?
+ public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, grantID: String? = nil, apiVersion: String = "st3.client.v0") {
+ self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS; self.grantID = grantID
}
- private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms" }
+ private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms", grantID = "grant_id" }
public init(from decoder: Decoder) throws {
let box = try decoder.container(keyedBy: Keys.self)
apiVersion = try box.decode(String.self, forKey: .apiVersion)
device = try box.decode(String.self, forKey: .device)
ttlMS = try box.decodeIfPresent(UInt64.self, forKey: .ttlMS)
+ grantID = try box.decodeIfPresent(String.self, forKey: .grantID)
command = try PhoneCommand(from: decoder)
}
public func encode(to encoder: Encoder) throws {
var box = encoder.container(keyedBy: Keys.self)
try box.encode(apiVersion, forKey: .apiVersion); try box.encode(device, forKey: .device)
+ try box.encodeIfPresent(grantID, forKey: .grantID)
try box.encodeIfPresent(ttlMS, forKey: .ttlMS); try command.encode(to: encoder)
}
}
@@ -813,10 +816,12 @@ public struct DeviceResult: Codable, Sendable {
public let outcome: DeviceCommandOutcome
public let result: JSONValue?
public let errorCode: String?
- public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil) {
- self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode
+ public let binding: ClientResultBinding?
+ public let signature: DeviceSignature?
+ public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil, binding: ClientResultBinding? = nil, signature: DeviceSignature? = nil) {
+ self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode; self.binding = binding; self.signature = signature
}
- enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code" }
+ enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code", binding, signature }
}
public struct DeviceCommand: Codable, Sendable, Identifiable {
public let id, kind, revision, updatedAt, deviceID, sessionActor, issuedBy, issuedAt, expiresAt: String
@@ -824,9 +829,14 @@ public struct DeviceCommand: Codable, Sendable, Identifiable {
public let state: DeviceCommandState
public let result: JSONValue?
public let errorCode: String?
+ public let grantID, runID: String?
+ public let controlGrant: ClientControlGrant?
+ public let receiptClaimID: String?
private enum Keys: String, CodingKey {
case id, kind, revision, updatedAt = "updated_at", deviceID = "device_id", sessionActor = "session_actor"
case issuedBy = "issued_by", issuedAt = "issued_at", expiresAt = "expires_at", state, result, errorCode = "error_code"
+ case grantID = "grant_id", runID = "run_id", controlGrant = "control_grant"
+ case receiptClaimID = "receipt_claim_id"
}
public init(from decoder: Decoder) throws {
let box = try decoder.container(keyedBy: Keys.self)
@@ -836,6 +846,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable {
issuedBy = try box.decode(String.self, forKey: .issuedBy); issuedAt = try box.decode(String.self, forKey: .issuedAt)
expiresAt = try box.decode(String.self, forKey: .expiresAt); state = try box.decode(DeviceCommandState.self, forKey: .state)
result = try box.decodeIfPresent(JSONValue.self, forKey: .result); errorCode = try box.decodeIfPresent(String.self, forKey: .errorCode)
+ grantID = try box.decodeIfPresent(String.self, forKey: .grantID); runID = try box.decodeIfPresent(String.self, forKey: .runID)
+ controlGrant = try box.decodeIfPresent(ClientControlGrant.self, forKey: .controlGrant)
+ receiptClaimID = try box.decodeIfPresent(String.self, forKey: .receiptClaimID)
command = try PhoneCommand(from: decoder, resource: true)
}
public func encode(to encoder: Encoder) throws {
@@ -846,6 +859,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable {
try box.encode(issuedBy, forKey: .issuedBy); try box.encode(issuedAt, forKey: .issuedAt)
try box.encode(expiresAt, forKey: .expiresAt); try box.encode(state, forKey: .state)
try box.encodeIfPresent(result, forKey: .result); try box.encodeIfPresent(errorCode, forKey: .errorCode)
+ try box.encodeIfPresent(grantID, forKey: .grantID); try box.encodeIfPresent(runID, forKey: .runID)
+ try box.encodeIfPresent(controlGrant, forKey: .controlGrant)
+ try box.encodeIfPresent(receiptClaimID, forKey: .receiptClaimID)
try command.encode(to: encoder, resource: true)
}
}
@@ -994,11 +1010,50 @@ public enum HarnessInvalidAnswerReason: String, Codable, Sendable { case unknown
public struct HarnessInvalidAnswerDetails: Codable, Sendable { public let reason: HarnessInvalidAnswerReason }
public enum HarnessNotAnswerableReason: String, Codable, Sendable { case adapterMissing = "adapter_missing", adapterUnavailable = "adapter_unavailable" }
public struct HarnessNotAnswerableDetails: Codable, Sendable { public let reason: HarnessNotAnswerableReason }
+public enum ClientControlGrantLifecycleState: String, Codable, Sendable { case started, ended, revoked }
+public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiable {
+ public let id, kind, revision, updatedAt, grantID, runID, device, session: String
+ public let itemID: String?
+ public let state: ClientControlGrantLifecycleState
+ public let reason: String
+ public let sequence: UInt64
+ enum CodingKeys: String, CodingKey {
+ case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence
+ }
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = try values.decode(String.self, forKey: .id)
+ kind = try values.decode(String.self, forKey: .kind)
+ revision = try values.decode(String.self, forKey: .revision)
+ updatedAt = try values.decode(String.self, forKey: .updatedAt)
+ grantID = try values.decode(String.self, forKey: .grantID)
+ runID = try values.decode(String.self, forKey: .runID)
+ device = try values.decode(String.self, forKey: .device)
+ session = try values.decode(String.self, forKey: .session)
+ itemID = try values.decodeIfPresent(String.self, forKey: .itemID)
+ state = try values.decode(ClientControlGrantLifecycleState.self, forKey: .state)
+ reason = try values.decode(String.self, forKey: .reason)
+ sequence = try values.decode(UInt64.self, forKey: .sequence)
+ guard sequence >= 1 && sequence <= 9_007_199_254_740_991 else {
+ throw DecodingError.dataCorruptedError(forKey: .sequence, in: values, debugDescription: "Lifecycle sequence must be a positive JSON-safe integer")
+ }
+ }
+}
+/// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding.
+public struct ClientCommandsCollectionFrame: Codable, Sendable {
+ public let kind, id, collection: String
+ public let snapshot: Snapshot
+ public let items, upserts: [Resource]?
+ public let removes, order: [String]?
+ public let hasMore: Bool
+ enum CodingKeys: String, CodingKey { case kind, id, collection, snapshot, items, upserts, removes, order, hasMore = "has_more" }
+}
public enum Resource: Codable, Sendable, Identifiable {
case harnessQuestion(HarnessQuestionResource)
case connectedClient(ConnectedClientResource), usage(UsageResource)
case summary(SummaryResource)
case deviceCommand(DeviceCommand)
+ case clientControlGrantLifecycle(ClientControlGrantLifecycleResource)
case attention(AttentionResource), message(MessageResource), launch(LaunchResource), launchVariant(LaunchVariantResource), launchDecision(LaunchDecisionResource), launchApproval(LaunchApprovalResource), mission(MissionResource), work(WorkResource), agent(AgentResource), runtime(RuntimeResource), observer(ObserverResource), subscription(SubscriptionResource), lane(LaneResource), machine(MachineResource), device(DeviceResource), operation(OperationResource), history(HistoryResource), session(SessionResource), glass(GlassResource), ownedSet(OwnedSetResource)
/// A kind this client does not know, from a member newer than it: kept whole so an older
/// client skips it or shows it plainly instead of failing the page it came in.
@@ -1027,6 +1082,7 @@ public enum Resource: Codable, Sendable, Identifiable {
case "machine": self = .machine(try MachineResource(from: decoder))
case "device": self = .device(try DeviceResource(from: decoder))
case "device-command": self = .deviceCommand(try DeviceCommand(from: decoder))
+ case "client-control-grant-lifecycle": self = .clientControlGrantLifecycle(try ClientControlGrantLifecycleResource(from: decoder))
case "operation": self = .operation(try OperationResource(from: decoder))
case "history": self = .history(try HistoryResource(from: decoder))
case "session": self = .session(try SessionResource(from: decoder))
@@ -1058,6 +1114,7 @@ public enum Resource: Codable, Sendable, Identifiable {
case .machine(let v): try v.encode(to: encoder)
case .device(let v): try v.encode(to: encoder)
case .deviceCommand(let v): try v.encode(to: encoder)
+ case .clientControlGrantLifecycle(let v): try v.encode(to: encoder)
case .operation(let v): try v.encode(to: encoder)
case .history(let v): try v.encode(to: encoder)
case .session(let v): try v.encode(to: encoder)
@@ -1067,7 +1124,7 @@ public enum Resource: Codable, Sendable, Identifiable {
case .unknown(let v): try v.encode(to: encoder)
}
}
- public var id: String { switch self { case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } }
+ public var id: String { switch self { case .clientControlGrantLifecycle(let v): v.id; case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } }
}
/// A resource of a kind this client does not model: its header, and every field as sent.
public struct UnknownResource: Codable, Sendable, Identifiable {
diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift
new file mode 100644
index 00000000000..f7344082452
--- /dev/null
+++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlLifecycleTests.swift
@@ -0,0 +1,43 @@
+import Foundation
+import XCTest
+@testable import FractalClient
+
+final class ClientControlLifecycleTests: XCTestCase {
+ func testSharedCommandsLifecycleDecoders() throws {
+ var root = URL(fileURLWithPath: #filePath)
+ for _ in 0..<6 { root.deleteLastPathComponent() }
+ let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: root.appendingPathComponent("fixtures/clients/client-control-lifecycle-v1.json"))) as? [String: Any])
+ for row in try XCTUnwrap(fixture["resources"] as? [[String: Any]]) {
+ let bytes = try JSONSerialization.data(withJSONObject: row)
+ let resource = try JSONDecoder().decode(Resource.self, from: bytes)
+ guard case .clientControlGrantLifecycle(let lifecycle) = resource else { return XCTFail("Lifecycle decoded as unknown") }
+ XCTAssertEqual(lifecycle.grantID, row["grant_id"] as? String)
+ XCTAssertEqual(lifecycle.itemID, row["item_id"] as? String)
+ XCTAssertEqual(lifecycle.state.rawValue, row["state"] as? String)
+ XCTAssertEqual(lifecycle.sequence, (row["sequence"] as? NSNumber)?.uint64Value)
+ }
+ for row in try XCTUnwrap(fixture["frames"] as? [[String: Any]]) {
+ let frame = try JSONDecoder().decode(ClientCommandsCollectionFrame.self, from: JSONSerialization.data(withJSONObject: row))
+ XCTAssertEqual(frame.collection, "commands")
+ let lifecycle = try XCTUnwrap((frame.items ?? frame.upserts)?.first)
+ guard case .clientControlGrantLifecycle = lifecycle else { return XCTFail("Frame lost typed lifecycle row") }
+ }
+ for trace in try XCTUnwrap(fixture["traces"] as? [[String: Any]]) {
+ for step in try XCTUnwrap(trace["steps"] as? [[String: Any]]) where step["expected"] as? String == "refuse-invalid-frame" {
+ let resource = try XCTUnwrap(step["resource"] as? [String: Any])
+ XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: resource)))
+ var frame = try XCTUnwrap((fixture["frames"] as? [[String: Any]])?.first)
+ frame["items"] = [resource]
+ XCTAssertThrowsError(try JSONDecoder().decode(ClientCommandsCollectionFrame.self, from: JSONSerialization.data(withJSONObject: frame)))
+ }
+ }
+ for sequence in [UInt64(0), UInt64(9_007_199_254_740_992)] {
+ var resource = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first)
+ resource["sequence"] = sequence
+ XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: resource)))
+ }
+ var invalid = try XCTUnwrap((fixture["resources"] as? [[String: Any]])?.first)
+ invalid["state"] = "future-state"
+ XCTAssertThrowsError(try JSONDecoder().decode(Resource.self, from: JSONSerialization.data(withJSONObject: invalid)))
+ }
+}
diff --git a/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift
new file mode 100644
index 00000000000..91310745054
--- /dev/null
+++ b/clients/swift/FractalClient/Tests/FractalClientTests/ClientControlTests.swift
@@ -0,0 +1,37 @@
+import Foundation
+import CryptoKit
+import XCTest
+@testable import FractalClient
+
+final class ClientControlTests: XCTestCase {
+ private func base64url(_ value: String) throws -> Data {
+ let padded = value.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
+ return try XCTUnwrap(Data(base64Encoded: padded + String(repeating: "=", count: (4 - padded.count % 4) % 4)))
+ }
+ private func binding(_ object: Any) throws -> ClientResultBinding {
+ try JSONDecoder().decode(ClientResultBinding.self, from: JSONSerialization.data(withJSONObject: object))
+ }
+ func testSharedCanonicalAndSignatureVectors() throws {
+ var root = URL(fileURLWithPath: #filePath)
+ for _ in 0..<6 { root.deleteLastPathComponent() }
+ let fixture = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: root.appendingPathComponent("fixtures/clients/client-control-v1.golden.json"))) as? [String: Any])
+ let publicKey = try P256.Signing.PublicKey(x963Representation: base64url(XCTUnwrap(fixture["public_key_sec1_base64url"] as? String)))
+ let message = Data(try XCTUnwrap(fixture["message_domain_bytes"] as? String).utf8)
+ let messageSignature = try P256.Signing.ECDSASignature(rawRepresentation: base64url(XCTUnwrap(fixture["message_domain_signature_p1363_base64url"] as? String)))
+ XCTAssertTrue(publicKey.isValidSignature(messageSignature, for: message))
+ for vector in try XCTUnwrap(fixture["vectors"] as? [[String: Any]]) {
+ let input = try binding(XCTUnwrap(vector["input"]))
+ XCTAssertEqual(visibleStateCanonicalJson(input.visible_state), vector["canonical_visible_state"] as? String)
+ XCTAssertEqual(visibleStateDigest(input.visible_state), vector["visible_state_sha256"] as? String)
+ let bytes = clientResultCanonicalBytes(input)
+ XCTAssertEqual(bytes, Data(try XCTUnwrap(vector["canonical_bytes"] as? String).utf8))
+ let signature = try P256.Signing.ECDSASignature(rawRepresentation: base64url(XCTUnwrap(vector["signature_p1363_base64url"] as? String)))
+ XCTAssertTrue(publicKey.isValidSignature(signature, for: bytes))
+ for changed in try XCTUnwrap(vector["negative_bindings"] as? [Any]) {
+ XCTAssertFalse(publicKey.isValidSignature(signature, for: clientResultCanonicalBytes(try binding(changed))))
+ }
+ XCTAssertFalse(publicKey.isValidSignature(signature, for: message))
+ XCTAssertFalse(publicKey.isValidSignature(messageSignature, for: bytes))
+ }
+ }
+}
diff --git a/clients/typescript/fractal-client/Client.generated.ts b/clients/typescript/fractal-client/Client.generated.ts
index c6d8d44ebd4..94e943b3657 100644
--- a/clients/typescript/fractal-client/Client.generated.ts
+++ b/clients/typescript/fractal-client/Client.generated.ts
@@ -486,6 +486,10 @@ export class FractalClient {
return this.request('POST', '/v1/client/device-results', result, undefined, undefined, requestOptions);
}
+ async clientTestRevoke(device: string, requestOptions: RequestOptions = {}): Promise {
+ return this.request('POST', '/v1/client/test-clients/revoke', { device }, undefined, undefined, requestOptions);
+ }
+
async beginPairing(request: PairingBegin, requestOptions: RequestOptions = {}): Promise> {
return this.request('POST', '/v1/client/pairings', request, undefined, undefined, requestOptions);
}
diff --git a/clients/typescript/fractal-client/ClientControl.generated.ts b/clients/typescript/fractal-client/ClientControl.generated.ts
new file mode 100644
index 00000000000..9024c9c6c24
--- /dev/null
+++ b/clients/typescript/fractal-client/ClientControl.generated.ts
@@ -0,0 +1,60 @@
+// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT.
+// Source: crates/fractal-client-codegen/templates/ClientControl.ts.in.
+export const CLIENT_RESULT_PURPOSE = 'client.device-result.v1';
+export const CLIENT_RESULT_SIGNED_FIELDS = ['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code', 'visible_state_sha256'] as const;
+export interface ClientVisibleItem { id: string; title: string; body: string }
+export interface ClientVisibleState { route: string; screen: string; visible_item_ids: string[]; opened_item: ClientVisibleItem | null }
+export interface ClientResultBinding {
+ purpose: string; command_kind: string; device: string; session: string;
+ grant_id: string; run_id: string; command_id: string; outcome: string;
+ error_code: string | null; visible_state: ClientVisibleState;
+}
+export interface ClientControlGrant {
+ grant_id: string; run_id: string; device: string; session: string; actor_agent: string;
+ verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string;
+ item_id?: string;
+}
+export class ClientResultCanonicalizationError extends Error {
+ readonly code = 'invalid-unicode' as const;
+ constructor() {
+ super('Invalid Unicode in client result');
+ this.name = 'ClientResultCanonicalizationError';
+ }
+}
+function quote(value: string): string {
+ // RFC 8785 requires rejection of lone surrogates rather than replacement.
+ for (let i = 0; i < value.length; i++) {
+ const c = value.charCodeAt(i);
+ if (c >= 0xd800 && c <= 0xdbff) {
+ const next = value.charCodeAt(++i);
+ if (!(next >= 0xdc00 && next <= 0xdfff)) throw new ClientResultCanonicalizationError();
+ } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError();
+ }
+ return JSON.stringify(value);
+}
+/** RFC 8785 JCS for the generated closed string-only visible-state schema. */
+export function visibleStateCanonicalJson(state: ClientVisibleState): string {
+ const item = state.opened_item;
+ return '{"opened_item":' + (item === null ? 'null' : '{"body":' + quote(item.body) + ',"id":' + quote(item.id) + ',"title":' + quote(item.title) + '}')
+ + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen)
+ + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}';
+}
+export type ClientControlSha256 = (bytes: Uint8Array) => Promise;
+export const defaultWebCryptoSha256: ClientControlSha256 = async bytes =>
+ new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer));
+export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise {
+ const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state)));
+ return Array.from(digest, b => b.toString(16).padStart(2, '0')).join('');
+}
+/** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding.
+ * Never use this helper as message.send authorization. Enforce active grant first. */
+export async function clientResultCanonicalBytes(binding: ClientResultBinding, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise {
+ const digest = await visibleStateDigest(binding.visible_state, sha256);
+ const fields: [string, string | null][] = [
+ ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device],
+ ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id],
+ ['command_id', binding.command_id], ['outcome', binding.outcome], ['error_code', binding.error_code],
+ ['visible_state_sha256', digest],
+ ];
+ return new TextEncoder().encode(['smallclaims-claim-fields-v1', ...fields.map(([k, v]) => k + '=' + (v === null ? 'null' : quote(v)))].join('\n'));
+}
diff --git a/clients/typescript/fractal-client/Models.generated.ts b/clients/typescript/fractal-client/Models.generated.ts
index 19a491ae870..7c13754ecbc 100644
--- a/clients/typescript/fractal-client/Models.generated.ts
+++ b/clients/typescript/fractal-client/Models.generated.ts
@@ -1265,6 +1265,21 @@ export type Capability = {
version: number;
};
+export type ClientCommandReceipt = {
+ commandId: string;
+ commandKind: string;
+ device: string;
+ grantId?: string;
+ issuer: string;
+ reason?: ClientControlRefusal;
+ runId?: string;
+ screen?: string;
+ session?: string;
+ signedResult: (ClientResultBinding | null);
+ terminalState: DeviceCommandOutcome;
+ verifiedResultSignature: (DeviceSignature | null);
+};
+
export type ClientConnection = {
actor: string;
client?: string | null;
@@ -1286,6 +1301,34 @@ export type ClientConnections = {
member: string;
};
+export type ClientControlGrant = {
+ actor_agent: string;
+ device: string;
+ expires_at: Timestamp;
+ grant_id: string;
+ item_id?: string;
+ max_commands: number;
+ run_id: string;
+ screens: Array;
+ session: string;
+ state: string;
+ verbs: Array;
+};
+
+export type ClientControlGrantLifecycle = {
+ device: string;
+ grant_id: string;
+ item_id?: string;
+ kind: "client-control-grant-lifecycle";
+ reason: string;
+ run_id: string;
+ sequence: number;
+ session: string;
+ state: "started" | "ended" | "revoked";
+} & ResourceHeader;
+
+export type ClientControlRefusal = "device-not-test-client" | "grant-missing" | "grant-expired" | "screen-not-granted" | "verb-not-granted" | "budget-exhausted" | "actor-not-granted";
+
export type ClientDiagnosticBreadcrumb = "native-start" | "js-start" | "root-mounted" | "foreground" | "background" | "previous-launch-unclean";
export type ClientDiagnosticCaptureSource = "js" | "native-marker" | "metrickit";
@@ -1367,6 +1410,51 @@ export type ClientDiagnosticsBatch = {
version: 1;
};
+export type ClientResultBinding = {
+ command_id: string;
+ command_kind: "phone.inspect" | "phone.navigate";
+ device: string;
+ error_code: string | null;
+ grant_id: string;
+ outcome: DeviceCommandOutcome;
+ purpose: "client.device-result.v1";
+ run_id: string;
+ session: string;
+ visible_state: ClientVisibleState;
+};
+
+export type ClientTestEnrollment = {
+ consentRef: string;
+ device: string;
+ enrolledBy: string;
+ host: string;
+ instance: string;
+ kind: "ios" | "web" | "tui";
+ packetDigest: string;
+ pairingSubject: string;
+ publicKey: string;
+ session: string;
+};
+
+export type ClientTestRevocation = {
+ device: string;
+ enrollmentId: string;
+ revokedBy: string;
+};
+
+export type ClientVisibleItem = {
+ body: string;
+ id: string;
+ title: string;
+};
+
+export type ClientVisibleState = {
+ opened_item: (ClientVisibleItem | null);
+ route: string;
+ screen: string;
+ visible_item_ids: Array;
+};
+
export type CollectionCommand = ((AgentsPublicationSubscribe & { actor?: never; agent?: never; capability?: never; conversation?: never; incarnation?: never; limit?: never; person?: never; since_ms?: never; status?: never; subject?: never; terminal?: never; until_ms?: never; }) | ({
actor?: string | null;
collection: "commands";
@@ -1706,14 +1794,18 @@ export type Device = ResourceHeader & {
export type DeviceCommand = {
args: (PhoneInspectArgs | PhoneNavigateArgs | PhoneMessageReadArgs);
command_kind: "phone.inspect" | "phone.navigate" | "phone.message.read";
+ control_grant?: ClientControlGrant;
device_id: string;
error_code?: string;
expires_at: Timestamp;
+ grant_id?: string;
id: string;
issued_at: Timestamp;
issued_by: Id;
kind: "device-command";
+ receipt_claim_id?: string;
result?: unknown;
+ run_id?: string;
session_actor: Id;
state: DeviceCommandState;
} & ResourceHeader & ({
@@ -1733,18 +1825,21 @@ export type DeviceCommandRequest = ({
api_version: "st3.client.v0" | "fractal.client.v0";
args: PhoneInspectArgs;
device: string;
+ grant_id?: string;
kind: "phone.inspect";
ttl_ms?: number;
} | {
api_version: "st3.client.v0" | "fractal.client.v0";
args: PhoneNavigateArgs;
device: string;
+ grant_id?: string;
kind: "phone.navigate";
ttl_ms?: number;
} | {
api_version: "st3.client.v0" | "fractal.client.v0";
args: PhoneMessageReadArgs;
device: string;
+ grant_id?: string;
kind: "phone.message.read";
ttl_ms?: number;
});
@@ -1752,10 +1847,12 @@ export type DeviceCommandRequest = ({
export type DeviceCommandState = "pending" | "delivered" | "completed" | "not-foreground" | "expired" | "refused" | "failed";
export type DeviceResult = {
+ binding?: ClientResultBinding;
command_id: string;
error_code?: string;
outcome: DeviceCommandOutcome;
result?: unknown;
+ signature?: DeviceSignature;
};
export type DeviceSignature = {
@@ -3319,7 +3416,7 @@ export type RequestSubject = {
url?: string;
};
-export type Resource = (ConnectedClient | Usage | Summary | Attention | Message | Launch | LaunchVariant | LaunchDecision | LaunchApproval | Mission | Work | Agent | Runtime | Observer | Subscription | Lane | Machine | Device | DeviceCommand | Operation | History | Session | Glass | Arrangement | OwnedSet | HarnessQuestion);
+export type Resource = (ConnectedClient | Usage | Summary | Attention | Message | Launch | LaunchVariant | LaunchDecision | LaunchApproval | Mission | Work | Agent | Runtime | Observer | Subscription | Lane | Machine | Device | DeviceCommand | ClientControlGrantLifecycle | Operation | History | Session | Glass | Arrangement | OwnedSet | HarnessQuestion);
export type ResourceCollectionName =
"missions"
diff --git a/clients/typescript/fractal-client/ReadSchema.generated.ts b/clients/typescript/fractal-client/ReadSchema.generated.ts
index b6b6a73dc95..f753e78ff01 100644
--- a/clients/typescript/fractal-client/ReadSchema.generated.ts
+++ b/clients/typescript/fractal-client/ReadSchema.generated.ts
@@ -1668,6 +1668,40 @@ export const ClientConnections = /*#__PURE__*/ (() => Schema.Struct({
export type ClientConnections = typeof ClientConnections.Type
export type ClientConnectionsEncoded = typeof ClientConnections.Encoded
+export const ClientControlGrant = /*#__PURE__*/ (() => Schema.Struct({
+ "actor_agent": Schema.String,
+ "device": Schema.String,
+ "expires_at": Timestamp,
+ "grant_id": Schema.String,
+ "item_id": optionalKey(Schema.String),
+ "max_commands": Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(9007199254740991)),
+ "run_id": Schema.String,
+ "screens": Schema.Array(Schema.String),
+ "session": Schema.String,
+ "state": Schema.String,
+ "verbs": Schema.Array(Schema.String)
+}).annotate({ identifier: "ClientControlGrant" }))()
+export type ClientControlGrant = typeof ClientControlGrant.Type
+export type ClientControlGrantEncoded = typeof ClientControlGrant.Encoded
+
+export const ClientControlGrantLifecycle = /*#__PURE__*/ (() => Schema.Struct({
+ "device": Schema.String,
+ "grant_id": Schema.String,
+ "id": Id,
+ "item_id": optionalKey(Schema.String),
+ "kind": Schema.Literal("client-control-grant-lifecycle"),
+ "operational": optionalKey(Operational),
+ "reason": Schema.String,
+ "revision": Revision,
+ "run_id": Schema.String,
+ "sequence": Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(9007199254740991)),
+ "session": Schema.String,
+ "state": Schema.Literals(["started","ended","revoked"]),
+ "updated_at": Timestamp
+}).annotate({ identifier: "ClientControlGrantLifecycle" }))()
+export type ClientControlGrantLifecycle = typeof ClientControlGrantLifecycle.Type
+export type ClientControlGrantLifecycleEncoded = typeof ClientControlGrantLifecycle.Encoded
+
/** One conversation header field: its latest value, where it came from and when. */
export const ConversationHeaderField = /*#__PURE__*/ (() => Schema.Struct({
"as_of": Timestamp,
@@ -1896,9 +1930,9 @@ export type PhoneNavigateArgs = typeof PhoneNavigateArgs.Type
export type PhoneNavigateArgsEncoded = typeof PhoneNavigateArgs.Encoded
export const DeviceCommand = /*#__PURE__*/ (() => Schema.Union([
- Schema.Struct({ "args": PhoneInspectArgs, "command_kind": Schema.Literal("phone.inspect"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }),
- Schema.Struct({ "args": PhoneNavigateArgs, "command_kind": Schema.Literal("phone.navigate"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }),
- Schema.Struct({ "args": PhoneMessageReadArgs, "command_kind": Schema.Literal("phone.message.read"), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "result": optionalKey(Schema.Unknown), "revision": Revision, "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp })
+ Schema.Struct({ "args": PhoneInspectArgs, "command_kind": Schema.Literal("phone.inspect"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }),
+ Schema.Struct({ "args": PhoneNavigateArgs, "command_kind": Schema.Literal("phone.navigate"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp }),
+ Schema.Struct({ "args": PhoneMessageReadArgs, "command_kind": Schema.Literal("phone.message.read"), "control_grant": optionalKey(ClientControlGrant), "device_id": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))), "expires_at": Timestamp, "grant_id": optionalKey(Schema.String), "id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))), "issued_at": Timestamp, "issued_by": Id, "kind": Schema.Literal("device-command"), "operational": optionalKey(Operational), "receipt_claim_id": optionalKey(Schema.String), "result": optionalKey(Schema.Unknown), "revision": Revision, "run_id": optionalKey(Schema.String), "session_actor": Id, "state": DeviceCommandState, "updated_at": Timestamp })
], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("command_kind")).annotate({ identifier: "DeviceCommand" }))()
export type DeviceCommand = typeof DeviceCommand.Type
export type DeviceCommandEncoded = typeof DeviceCommand.Encoded
@@ -2804,7 +2838,7 @@ export type SummaryEncoded = typeof Summary.Encoded
export const UnknownResource = /*#__PURE__*/ (() => Schema.Struct({
"id": Id,
- "kind": unknownCase(["attention","message","launch","launch-variant","launch-decision","launch-approval","mission","work","agent","runtime","observer","subscription","lane","machine","device","device-command","operation","history","session","glass","arrangement","owned-set","summary","connected-client","usage","harness-question"]),
+ "kind": unknownCase(["attention","message","launch","launch-variant","launch-decision","launch-approval","mission","work","agent","runtime","observer","subscription","lane","machine","device","device-command","client-control-grant-lifecycle","operation","history","session","glass","arrangement","owned-set","summary","connected-client","usage","harness-question"]),
"operational": optionalKey(Operational),
"revision": Revision,
"updated_at": Timestamp
@@ -3048,6 +3082,7 @@ export const Resource = /*#__PURE__*/ (() => Schema.Union([
Machine,
Device,
DeviceCommand,
+ ClientControlGrantLifecycle,
Operation,
History,
Session,
diff --git a/clients/typescript/fractal-client/Schema.generated.ts b/clients/typescript/fractal-client/Schema.generated.ts
index bcae26143dc..4d79e62706c 100644
--- a/clients/typescript/fractal-client/Schema.generated.ts
+++ b/clients/typescript/fractal-client/Schema.generated.ts
@@ -10,8 +10,8 @@ import type { SubjectRef, UnknownCase } from "./SchemaSupport.generated.ts"
export { API_VERSION, containsUnknownCase, decodeUnknownSync, decodeUnknownEffect } from "./SchemaSupport.generated.ts"
export type { SubjectRef, UnknownCase, DecodeMode } from "./SchemaSupport.generated.ts"
-import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts"
-import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts"
+import { ActorRef, Agent, AgentCheckout, AgentDeclaration, AgentDelivery, AgentEndReason, AgentHandoff, AgentId, AgentLifecycle, AgentMailCustody, AgentMessageDay, AgentMessageEstimate, AgentPresentation, AgentPresentationNow, AgentPresentationWork, AgentQueue, AgentQueueMove, AgentQueueRun, AgentReachability, AgentRelatedRun, AgentRelatedRuns, AgentRelationship, AgentRepository, AgentState, AgentStopping, AgentSubagent, AgentSuspension, AgentTodo, AgentWorkspace, AgentsChanges, AgentsPublicationMetadata, AgentsPublicationSubscribe, AgentsSnapshotChunk, AgentsStatusWatermark, Arrangement, ArrangementBody, ArrangementBodyV1, ArrangementBodyV2, ArrangementFolder, ArrangementFolderId, ArrangementId, ArrangementKey, ArrangementName, ArrangementNameRegister, ArrangementPage, ArrangementPerson, ArrangementPlacement, ArrangementPlacementRegister, ArrangementPosition, ArrangementPositionRegister, ArrangementResolved, ArrangementSubject, ArrangementTombstoneRegister, AskDefaultAnswer, AssistantAgentDetailRequest, AssistantAgentItem, AssistantAgentPage, AssistantAttentionItem, AssistantAttentionPage, AssistantContentRange, AssistantConversationContent, AssistantConversationContentRequest, AssistantConversationSearchRequest, AssistantConversationTimelineRequest, AssistantMinimalReadMode, AssistantMissionItem, AssistantMissionPage, AssistantPageRequest, AssistantPrincipal, AssistantProjection, AssistantResponseBytes, AssistantSenderClass, AssistantSummary, AssistantSummaryRequest, Attachment, Attention, AttentionActor, AttentionBlocked, AttentionBudgetDeclared, AttentionBudgetReport, AttentionBudgetRequestRef, AttentionLaunchAgent, AttentionLaunchAssignee, AttentionLaunchPreview, AttentionLaunchStep, AttentionLaunchUnavailable, AttentionLaunchUnavailableReason, AttentionPresentation, AttentionPrompt, AttentionPromptCall, AttentionRevision, AttentionRevisionLine, AttentionTargetState, AttentionWaiting, BlobChunk, CanonicalNode, Capabilities, Capability, ClientConnection, ClientConnections, ClientControlGrant, ClientControlGrantLifecycle, CollectionName, ConnectedClient, ConversationChanges, ConversationContentChunk, ConversationContentRef, ConversationHeader, ConversationHeaderField, ConversationSearch, ConversationSearchHit, Cursor, DecisionOption, DecisionResponse, DeclaredAdmission, DeclaredAttentionBudget, DeclaredBudgets, DeclaredLane, DeclaredPriority, DeclaredResource, Device, DeviceCommand, DeviceCommandState, Diagnostic, DirectiveNote, DirectiveNotes, DocumentContent, DocumentItem, DocumentReference, DocumentReferences, DocumentsPage, ErrorCode, EventPage, ExchangeTransport, Fence, Glass, GlassBody, GlassLayout, GlassTab, HarnessInventory, HarnessInventoryItem, HarnessPhase, HarnessQuestion, HarnessQuestionAnswerability, HarnessQuestionAnswerabilityReason, HarnessQuestionItem, HarnessQuestionResolution, HarnessResolvedAnswer, HarnessTask, HarnessTodoSnapshot, HarnessTodoTotals, History, HostId, HostRepositories, Id, JobSummary, Lane, LaneChange, LaneEntry, Launch, LaunchApproval, LaunchDecision, LaunchPreview, LaunchVariant, Machine, MachineCapacity, MachineOccupancy, MachineTransport, MailBacklog, Message, MessageBody, MessageProvenance, MessageStatusFrom, Mission, MissionActionOffer, MissionDeclaration, MissionDeclarationProvenance, MissionId, MissionPresentation, MissionQueuedBehind, MissionRunId, MissionRunOutcome, MissionRunSummary, MissionState, MissionStep, MissionStepAttempt, MissionStepEta, MissionStepPresentation, MissionStepTransition, MissionStepWait, MissionStuck, MissionWake, MustAct, Observer, ObserverSpec, Operation, Operational, OrderedMembership, OrderedMembershipPage, OrderedMembershipPosition, OrderedMembershipState, OwnedSet, Page, PageInfo, PairingAdvertisement, ParticipantRef, PersonAnswer, PersonAnswerRecord, PersonId, PersonOptionNote, PersonUpdate, PhoneInspectArgs, PhoneMessageReadArgs, PhoneNavigateArgs, PhoneRouteString, PlannerConfig, PresentationActor, PrivateNotesAction, PrivateNotesData, PrivateNotesFence, PrivateNotesSubject, PrivateNotesUri, ProjectionEvent, PublicationDefinition, PushEnvironment, PushRegistrationStatus, ReadCollectionCommand, ReadCollectionFrame, RelatedRunWait, ReplicatedNotice, RequestAnswerOption, RequestSubject, Resource, ResourceCollectionName, ResourceHeader, ResourceObservation, ResourcesPage, RespondentRef, Revision, RunGenerationId, RunOutcomeStatus, Runtime, RuntimeId, ScgReadAuthorization, ScgReadAuthorizationPayload, ScheduleCoverage, ScheduleDeclaration, ScheduleKnowledge, ScheduleOwner, ScheduleUnheardHost, SchedulesPage, SenderRef, Session, SloHistory, SloList, SloRow, Snapshot, SnapshotId, StatusHistory, StatusTransition, StepRunId, StreamSync, StructuredDiff, StructuredRequest, SubagentSummary, SubjectDefinition, SubscribeTraceContext, Subscription, SubscriptionSpec, Summary, SummaryMachines, SyncNotice, SyncPeer, TimelineAskAnswer, TimelineAskQuestion, TimelineBlock, TimelineContentBody, TimelineDelta, TimelineEntry, TimelineEntryId, TimelineErrorBody, TimelineMessageBody, TimelinePage, TimelineRecipientRef, TimelineRedactionBody, TimelineStatus, TimelineStatusBody, TimelineTodoItem, TimelineToolCallBody, TimelineToolResultBody, TimelineTruncationBody, TimelineUsageBody, TimelineView, TimelineViewAsk, TimelineViewAssistantError, TimelineViewBash, TimelineViewCompaction, TimelineViewCredentialPin, TimelineViewEdit, TimelineViewEval, TimelineViewGeneric, TimelineViewHub, TimelineViewIrc, TimelineViewJob, TimelineViewModelChange, TimelineViewOmpEvent, TimelineViewRead, TimelineViewResetBoundary, TimelineViewSearch, TimelineViewSessionExit, TimelineViewSkill, TimelineViewTask, TimelineViewThinkingLevel, TimelineViewTitle, TimelineViewTodo, TimelineViewToolStart, TimelineViewWrite, Timestamp, UnknownResource, Usage, UsageAccount, UsageContext, UsageCredits, UsageLimit, UsagePeriod, UsagePricing, UsagePricingRates, UsageRow, UsageSummary, Value, Visualization, VisualizationEdge, VisualizationGroup, VisualizationNode, Work, WorkLabel, WorkState } from "./ReadSchema.generated.ts"
+import type { ActorRefEncoded, AgentEncoded, AgentCheckoutEncoded, AgentDeclarationEncoded, AgentDeliveryEncoded, AgentEndReasonEncoded, AgentHandoffEncoded, AgentIdEncoded, AgentLifecycleEncoded, AgentMailCustodyEncoded, AgentMessageDayEncoded, AgentMessageEstimateEncoded, AgentPresentationEncoded, AgentPresentationNowEncoded, AgentPresentationWorkEncoded, AgentQueueEncoded, AgentQueueMoveEncoded, AgentQueueRunEncoded, AgentReachabilityEncoded, AgentRelatedRunEncoded, AgentRelatedRunsEncoded, AgentRelationshipEncoded, AgentRepositoryEncoded, AgentStateEncoded, AgentStoppingEncoded, AgentSubagentEncoded, AgentSuspensionEncoded, AgentTodoEncoded, AgentWorkspaceEncoded, AgentsChangesEncoded, AgentsPublicationMetadataEncoded, AgentsPublicationSubscribeEncoded, AgentsSnapshotChunkEncoded, AgentsStatusWatermarkEncoded, ArrangementEncoded, ArrangementBodyEncoded, ArrangementBodyV1Encoded, ArrangementBodyV2Encoded, ArrangementFolderEncoded, ArrangementFolderIdEncoded, ArrangementIdEncoded, ArrangementKeyEncoded, ArrangementNameEncoded, ArrangementNameRegisterEncoded, ArrangementPageEncoded, ArrangementPersonEncoded, ArrangementPlacementEncoded, ArrangementPlacementRegisterEncoded, ArrangementPositionEncoded, ArrangementPositionRegisterEncoded, ArrangementResolvedEncoded, ArrangementSubjectEncoded, ArrangementTombstoneRegisterEncoded, AskDefaultAnswerEncoded, AssistantAgentDetailRequestEncoded, AssistantAgentItemEncoded, AssistantAgentPageEncoded, AssistantAttentionItemEncoded, AssistantAttentionPageEncoded, AssistantContentRangeEncoded, AssistantConversationContentEncoded, AssistantConversationContentRequestEncoded, AssistantConversationSearchRequestEncoded, AssistantConversationTimelineRequestEncoded, AssistantMinimalReadModeEncoded, AssistantMissionItemEncoded, AssistantMissionPageEncoded, AssistantPageRequestEncoded, AssistantPrincipalEncoded, AssistantProjectionEncoded, AssistantResponseBytesEncoded, AssistantSenderClassEncoded, AssistantSummaryEncoded, AssistantSummaryRequestEncoded, AttachmentEncoded, AttentionEncoded, AttentionActorEncoded, AttentionBlockedEncoded, AttentionBudgetDeclaredEncoded, AttentionBudgetReportEncoded, AttentionBudgetRequestRefEncoded, AttentionLaunchAgentEncoded, AttentionLaunchAssigneeEncoded, AttentionLaunchPreviewEncoded, AttentionLaunchStepEncoded, AttentionLaunchUnavailableEncoded, AttentionLaunchUnavailableReasonEncoded, AttentionPresentationEncoded, AttentionPromptEncoded, AttentionPromptCallEncoded, AttentionRevisionEncoded, AttentionRevisionLineEncoded, AttentionTargetStateEncoded, AttentionWaitingEncoded, BlobChunkEncoded, CanonicalNodeEncoded, CapabilitiesEncoded, CapabilityEncoded, ClientConnectionEncoded, ClientConnectionsEncoded, ClientControlGrantEncoded, ClientControlGrantLifecycleEncoded, CollectionNameEncoded, ConnectedClientEncoded, ConversationChangesEncoded, ConversationContentChunkEncoded, ConversationContentRefEncoded, ConversationHeaderEncoded, ConversationHeaderFieldEncoded, ConversationSearchEncoded, ConversationSearchHitEncoded, CursorEncoded, DecisionOptionEncoded, DecisionResponseEncoded, DeclaredAdmissionEncoded, DeclaredAttentionBudgetEncoded, DeclaredBudgetsEncoded, DeclaredLaneEncoded, DeclaredPriorityEncoded, DeclaredResourceEncoded, DeviceEncoded, DeviceCommandEncoded, DeviceCommandStateEncoded, DiagnosticEncoded, DirectiveNoteEncoded, DirectiveNotesEncoded, DocumentContentEncoded, DocumentItemEncoded, DocumentReferenceEncoded, DocumentReferencesEncoded, DocumentsPageEncoded, ErrorCodeEncoded, EventPageEncoded, ExchangeTransportEncoded, FenceEncoded, GlassEncoded, GlassBodyEncoded, GlassLayoutEncoded, GlassTabEncoded, HarnessInventoryEncoded, HarnessInventoryItemEncoded, HarnessPhaseEncoded, HarnessQuestionEncoded, HarnessQuestionAnswerabilityEncoded, HarnessQuestionAnswerabilityReasonEncoded, HarnessQuestionItemEncoded, HarnessQuestionResolutionEncoded, HarnessResolvedAnswerEncoded, HarnessTaskEncoded, HarnessTodoSnapshotEncoded, HarnessTodoTotalsEncoded, HistoryEncoded, HostIdEncoded, HostRepositoriesEncoded, IdEncoded, JobSummaryEncoded, LaneEncoded, LaneChangeEncoded, LaneEntryEncoded, LaunchEncoded, LaunchApprovalEncoded, LaunchDecisionEncoded, LaunchPreviewEncoded, LaunchVariantEncoded, MachineEncoded, MachineCapacityEncoded, MachineOccupancyEncoded, MachineTransportEncoded, MailBacklogEncoded, MessageEncoded, MessageBodyEncoded, MessageProvenanceEncoded, MessageStatusFromEncoded, MissionEncoded, MissionActionOfferEncoded, MissionDeclarationEncoded, MissionDeclarationProvenanceEncoded, MissionIdEncoded, MissionPresentationEncoded, MissionQueuedBehindEncoded, MissionRunIdEncoded, MissionRunOutcomeEncoded, MissionRunSummaryEncoded, MissionStateEncoded, MissionStepEncoded, MissionStepAttemptEncoded, MissionStepEtaEncoded, MissionStepPresentationEncoded, MissionStepTransitionEncoded, MissionStepWaitEncoded, MissionStuckEncoded, MissionWakeEncoded, MustActEncoded, ObserverEncoded, ObserverSpecEncoded, OperationEncoded, OperationalEncoded, OrderedMembershipEncoded, OrderedMembershipPageEncoded, OrderedMembershipPositionEncoded, OrderedMembershipStateEncoded, OwnedSetEncoded, PageEncoded, PageInfoEncoded, PairingAdvertisementEncoded, ParticipantRefEncoded, PersonAnswerEncoded, PersonAnswerRecordEncoded, PersonIdEncoded, PersonOptionNoteEncoded, PersonUpdateEncoded, PhoneInspectArgsEncoded, PhoneMessageReadArgsEncoded, PhoneNavigateArgsEncoded, PhoneRouteStringEncoded, PlannerConfigEncoded, PresentationActorEncoded, PrivateNotesActionEncoded, PrivateNotesDataEncoded, PrivateNotesFenceEncoded, PrivateNotesSubjectEncoded, PrivateNotesUriEncoded, ProjectionEventEncoded, PublicationDefinitionEncoded, PushEnvironmentEncoded, PushRegistrationStatusEncoded, ReadCollectionCommandEncoded, ReadCollectionFrameEncoded, RelatedRunWaitEncoded, ReplicatedNoticeEncoded, RequestAnswerOptionEncoded, RequestSubjectEncoded, ResourceEncoded, ResourceCollectionNameEncoded, ResourceHeaderEncoded, ResourceObservationEncoded, ResourcesPageEncoded, RespondentRefEncoded, RevisionEncoded, RunGenerationIdEncoded, RunOutcomeStatusEncoded, RuntimeEncoded, RuntimeIdEncoded, ScgReadAuthorizationEncoded, ScgReadAuthorizationPayloadEncoded, ScheduleCoverageEncoded, ScheduleDeclarationEncoded, ScheduleKnowledgeEncoded, ScheduleOwnerEncoded, ScheduleUnheardHostEncoded, SchedulesPageEncoded, SenderRefEncoded, SessionEncoded, SloHistoryEncoded, SloListEncoded, SloRowEncoded, SnapshotEncoded, SnapshotIdEncoded, StatusHistoryEncoded, StatusTransitionEncoded, StepRunIdEncoded, StreamSyncEncoded, StructuredDiffEncoded, StructuredRequestEncoded, SubagentSummaryEncoded, SubjectDefinitionEncoded, SubscribeTraceContextEncoded, SubscriptionEncoded, SubscriptionSpecEncoded, SummaryEncoded, SummaryMachinesEncoded, SyncNoticeEncoded, SyncPeerEncoded, TimelineAskAnswerEncoded, TimelineAskQuestionEncoded, TimelineBlockEncoded, TimelineContentBodyEncoded, TimelineDeltaEncoded, TimelineEntryEncoded, TimelineEntryIdEncoded, TimelineErrorBodyEncoded, TimelineMessageBodyEncoded, TimelinePageEncoded, TimelineRecipientRefEncoded, TimelineRedactionBodyEncoded, TimelineStatusEncoded, TimelineStatusBodyEncoded, TimelineTodoItemEncoded, TimelineToolCallBodyEncoded, TimelineToolResultBodyEncoded, TimelineTruncationBodyEncoded, TimelineUsageBodyEncoded, TimelineViewEncoded, TimelineViewAskEncoded, TimelineViewAssistantErrorEncoded, TimelineViewBashEncoded, TimelineViewCompactionEncoded, TimelineViewCredentialPinEncoded, TimelineViewEditEncoded, TimelineViewEvalEncoded, TimelineViewGenericEncoded, TimelineViewHubEncoded, TimelineViewIrcEncoded, TimelineViewJobEncoded, TimelineViewModelChangeEncoded, TimelineViewOmpEventEncoded, TimelineViewReadEncoded, TimelineViewResetBoundaryEncoded, TimelineViewSearchEncoded, TimelineViewSessionExitEncoded, TimelineViewSkillEncoded, TimelineViewTaskEncoded, TimelineViewThinkingLevelEncoded, TimelineViewTitleEncoded, TimelineViewTodoEncoded, TimelineViewToolStartEncoded, TimelineViewWriteEncoded, TimestampEncoded, UnknownResourceEncoded, UsageEncoded, UsageAccountEncoded, UsageContextEncoded, UsageCreditsEncoded, UsageLimitEncoded, UsagePeriodEncoded, UsagePricingEncoded, UsagePricingRatesEncoded, UsageRowEncoded, UsageSummaryEncoded, ValueEncoded, VisualizationEncoded, VisualizationEdgeEncoded, VisualizationGroupEncoded, VisualizationNodeEncoded, WorkEncoded, WorkLabelEncoded, WorkStateEncoded } from "./ReadSchema.generated.ts"
export * from "./ReadSchema.generated.ts"
export const ActionCommon = /*#__PURE__*/ (() => Schema.Struct({
@@ -585,6 +585,63 @@ export const BlobUpload = /*#__PURE__*/ (() => Schema.Struct({
export type BlobUpload = typeof BlobUpload.Type
export type BlobUploadEncoded = typeof BlobUpload.Encoded
+export const ClientControlRefusal = /*#__PURE__*/ (() => Schema.Literals(["device-not-test-client","grant-missing","grant-expired","screen-not-granted","verb-not-granted","budget-exhausted","actor-not-granted"]).annotate({ identifier: "ClientControlRefusal" }))()
+export type ClientControlRefusal = typeof ClientControlRefusal.Type
+export type ClientControlRefusalEncoded = typeof ClientControlRefusal.Encoded
+
+export const ClientVisibleItem = /*#__PURE__*/ (() => Schema.Struct({
+ "body": Schema.String,
+ "id": Schema.String,
+ "title": Schema.String
+}).annotate({ identifier: "ClientVisibleItem" }))()
+export type ClientVisibleItem = typeof ClientVisibleItem.Type
+export type ClientVisibleItemEncoded = typeof ClientVisibleItem.Encoded
+
+export const ClientVisibleState = /*#__PURE__*/ (() => Schema.Struct({
+ "opened_item": Schema.OptionFromNullOr(ClientVisibleItem),
+ "route": Schema.String,
+ "screen": Schema.String,
+ "visible_item_ids": Schema.Array(Schema.String)
+}).annotate({ identifier: "ClientVisibleState" }))()
+export type ClientVisibleState = typeof ClientVisibleState.Type
+export type ClientVisibleStateEncoded = typeof ClientVisibleState.Encoded
+
+export const DeviceCommandOutcome = /*#__PURE__*/ (() => Schema.Literals(["completed","not-foreground","expired","refused","failed"]).annotate({ identifier: "DeviceCommandOutcome" }))()
+export type DeviceCommandOutcome = typeof DeviceCommandOutcome.Type
+export type DeviceCommandOutcomeEncoded = typeof DeviceCommandOutcome.Encoded
+
+export const ClientResultBinding = /*#__PURE__*/ (() => Schema.Struct({
+ "command_id": Schema.String,
+ "command_kind": Schema.Literals(["phone.inspect","phone.navigate"]),
+ "device": Schema.String,
+ "error_code": Schema.OptionFromNullOr(Schema.String),
+ "grant_id": Schema.String,
+ "outcome": DeviceCommandOutcome,
+ "purpose": Schema.Literal("client.device-result.v1"),
+ "run_id": Schema.String,
+ "session": Schema.String,
+ "visible_state": ClientVisibleState
+}).annotate({ identifier: "ClientResultBinding" }))()
+export type ClientResultBinding = typeof ClientResultBinding.Type
+export type ClientResultBindingEncoded = typeof ClientResultBinding.Encoded
+
+export const ClientCommandReceipt = /*#__PURE__*/ (() => Schema.Struct({
+ "commandId": Schema.String,
+ "commandKind": Schema.String,
+ "device": Schema.String,
+ "grantId": optionalKey(Schema.String),
+ "issuer": Schema.String,
+ "reason": optionalKey(ClientControlRefusal),
+ "runId": optionalKey(Schema.String),
+ "screen": optionalKey(Schema.String),
+ "session": optionalKey(Schema.String),
+ "signedResult": Schema.OptionFromNullOr(ClientResultBinding),
+ "terminalState": DeviceCommandOutcome,
+ "verifiedResultSignature": Schema.OptionFromNullOr(DeviceSignature)
+}).annotate({ identifier: "ClientCommandReceipt" }))()
+export type ClientCommandReceipt = typeof ClientCommandReceipt.Type
+export type ClientCommandReceiptEncoded = typeof ClientCommandReceipt.Encoded
+
export const ClientDiagnosticBreadcrumb = /*#__PURE__*/ (() => Schema.Literals(["native-start","js-start","root-mounted","foreground","background","previous-launch-unclean"]).annotate({ identifier: "ClientDiagnosticBreadcrumb" }))()
export type ClientDiagnosticBreadcrumb = typeof ClientDiagnosticBreadcrumb.Type
export type ClientDiagnosticBreadcrumbEncoded = typeof ClientDiagnosticBreadcrumb.Encoded
@@ -693,6 +750,29 @@ export const ClientDiagnosticsBatch = /*#__PURE__*/ (() => Schema.Struct({
export type ClientDiagnosticsBatch = typeof ClientDiagnosticsBatch.Type
export type ClientDiagnosticsBatchEncoded = typeof ClientDiagnosticsBatch.Encoded
+export const ClientTestEnrollment = /*#__PURE__*/ (() => Schema.Struct({
+ "consentRef": Schema.String,
+ "device": Schema.String,
+ "enrolledBy": Schema.String,
+ "host": Schema.String,
+ "instance": Schema.String,
+ "kind": Schema.Literals(["ios","web","tui"]),
+ "packetDigest": Schema.String,
+ "pairingSubject": Schema.String,
+ "publicKey": Schema.String,
+ "session": Schema.String
+}).annotate({ identifier: "ClientTestEnrollment" }))()
+export type ClientTestEnrollment = typeof ClientTestEnrollment.Type
+export type ClientTestEnrollmentEncoded = typeof ClientTestEnrollment.Encoded
+
+export const ClientTestRevocation = /*#__PURE__*/ (() => Schema.Struct({
+ "device": Schema.String,
+ "enrollmentId": Schema.String,
+ "revokedBy": Schema.String
+}).annotate({ identifier: "ClientTestRevocation" }))()
+export type ClientTestRevocation = typeof ClientTestRevocation.Type
+export type ClientTestRevocationEncoded = typeof ClientTestRevocation.Encoded
+
export const CollectionCommand = /*#__PURE__*/ (() => Schema.Union([
Schema.Struct({ "agents_publication_version": Schema.Literal(1), "collection": Schema.Literal("agents"), "id": Schema.String.check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128)), "kind": Schema.Literal("subscribe"), "trace": optionalKey(SubscribeTraceContext), "actor": Schema.optionalKey(Schema.Never), "agent": Schema.optionalKey(Schema.Never), "capability": Schema.optionalKey(Schema.Never), "conversation": Schema.optionalKey(Schema.Never), "incarnation": Schema.optionalKey(Schema.Never), "limit": Schema.optionalKey(Schema.Never), "person": Schema.optionalKey(Schema.Never), "since_ms": Schema.optionalKey(Schema.Never), "status": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Never), "terminal": Schema.optionalKey(Schema.Never), "until_ms": Schema.optionalKey(Schema.Never) }),
Schema.Struct({ "actor": Schema.OptionFromOptionalNullOr(Schema.String, NULL_NONE), "collection": Schema.Literal("commands"), "id": Schema.String.check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128)), "kind": Schema.Literal("subscribe"), "limit": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(200))), "status": Schema.OptionFromOptionalNullOr(Schema.String, NULL_NONE), "trace": optionalKey(SubscribeTraceContext), "agent": Schema.optionalKey(Schema.Never), "agents_publication_version": Schema.optionalKey(Schema.Never), "capability": Schema.optionalKey(Schema.Never), "conversation": Schema.optionalKey(Schema.Never), "incarnation": Schema.optionalKey(Schema.Never), "person": Schema.optionalKey(Schema.Never), "since_ms": Schema.optionalKey(Schema.Never), "subject": Schema.optionalKey(Schema.Never), "terminal": Schema.optionalKey(Schema.Never), "until_ms": Schema.optionalKey(Schema.Never) }),
@@ -834,25 +914,23 @@ export const CollectionFrame = /*#__PURE__*/ (() => Schema.Union([
export type CollectionFrame = typeof CollectionFrame.Type
export type CollectionFrameEncoded = typeof CollectionFrame.Encoded
-export const DeviceCommandOutcome = /*#__PURE__*/ (() => Schema.Literals(["completed","not-foreground","expired","refused","failed"]).annotate({ identifier: "DeviceCommandOutcome" }))()
-export type DeviceCommandOutcome = typeof DeviceCommandOutcome.Type
-export type DeviceCommandOutcomeEncoded = typeof DeviceCommandOutcome.Encoded
-
/** One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device. */
export const DeviceCommandRequest = /*#__PURE__*/ (() => Schema.Union([
- Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneInspectArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.inspect"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }),
- Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneNavigateArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.navigate"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }),
- Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneMessageReadArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "kind": Schema.Literal("phone.message.read"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) })
+ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneInspectArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.inspect"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }),
+ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneNavigateArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.navigate"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) }),
+ Schema.Struct({ "api_version": Schema.Literals(["st3.client.v0","fractal.client.v0"]), "args": PhoneMessageReadArgs, "device": Schema.String.check(Schema.isPattern(new RegExp("^device/.+", "u"))), "grant_id": optionalKey(Schema.String), "kind": Schema.Literal("phone.message.read"), "ttl_ms": optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)).check(Schema.isLessThanOrEqualTo(60000))) })
], { mode: "oneOf" }).pipe(Schema.toTaggedUnion("kind")).annotate({ identifier: "DeviceCommandRequest", description: "One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device." }))()
export type DeviceCommandRequest = typeof DeviceCommandRequest.Type
export type DeviceCommandRequestEncoded = typeof DeviceCommandRequest.Encoded
/** Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry. */
export const DeviceResult = /*#__PURE__*/ (() => Schema.Struct({
+ "binding": optionalKey(ClientResultBinding),
"command_id": Schema.String.check(Schema.isPattern(new RegExp("^device-command/.+", "u"))),
"error_code": optionalKey(Schema.String.check(Schema.isPattern(new RegExp("^[A-Za-z0-9_.-]+$", "u"))).check(Schema.isMinCodePoints(1)).check(Schema.isMaxCodePoints(128))),
"outcome": DeviceCommandOutcome,
- "result": optionalKey(Schema.Unknown)
+ "result": optionalKey(Schema.Unknown),
+ "signature": optionalKey(DeviceSignature)
}).annotate({ identifier: "DeviceResult", description: "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry." }))()
export type DeviceResult = typeof DeviceResult.Type
export type DeviceResultEncoded = typeof DeviceResult.Encoded
diff --git a/clients/typescript/fractal-client/client-control.test.cjs b/clients/typescript/fractal-client/client-control.test.cjs
new file mode 100644
index 00000000000..b59a1011d79
--- /dev/null
+++ b/clients/typescript/fractal-client/client-control.test.cjs
@@ -0,0 +1,57 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const ts = require('typescript');
+const { webcrypto, createPublicKey, createHash, verify } = require('node:crypto');
+const Module = require('node:module');
+const filename = path.join(__dirname, 'ClientControl.generated.ts');
+const source = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
+ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
+}).outputText;
+const loaded = new Module(filename, module);
+loaded.filename = filename;
+loaded.paths = module.paths;
+loaded._compile(source, filename);
+const { ClientResultCanonicalizationError, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } = loaded.exports;
+const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-v1.golden.json'), 'utf8'));
+if (!globalThis.crypto) globalThis.crypto = webcrypto;
+const point = Buffer.from(fixture.public_key_sec1_base64url, 'base64url');
+const key = createPublicKey({ key: { kty: 'EC', crv: 'P-256', x: point.subarray(1, 33).toString('base64url'), y: point.subarray(33).toString('base64url') }, format: 'jwk' });
+function verifies(bytes, signature) {
+ return verify('sha256', bytes, { key, dsaEncoding: 'ieee-p1363' }, signature);
+}
+test('all clients share canonical bytes, digest and purpose-specific fixed-key signatures', async () => {
+ const message = Buffer.from(fixture.message_domain_bytes);
+ const messageSignature = Buffer.from(fixture.message_domain_signature_p1363_base64url, 'base64url');
+ assert.ok(verifies(message, messageSignature));
+ for (const vector of fixture.vectors) {
+ assert.equal(visibleStateCanonicalJson(vector.input.visible_state), vector.canonical_visible_state);
+ assert.equal(await visibleStateDigest(vector.input.visible_state), vector.visible_state_sha256);
+ const bytes = await clientResultCanonicalBytes(vector.input);
+ assert.equal(Buffer.from(bytes).toString('utf8'), vector.canonical_bytes);
+ const signature = Buffer.from(vector.signature_p1363_base64url, 'base64url');
+ assert.ok(verifies(bytes, signature));
+ for (const changed of vector.negative_bindings) assert.equal(verifies(await clientResultCanonicalBytes(changed), signature), false);
+ assert.equal(verifies(message, signature), false);
+ assert.equal(verifies(bytes, messageSignature), false);
+ }
+});
+test('JCS exposes typed lone-surrogate refusals through canonical byte generation', async () => {
+ for (const route of ['\ud800', '\udc00']) {
+ const state = { ...fixture.vectors[0].input.visible_state, route };
+ const isInvalidUnicode = error => error instanceof ClientResultCanonicalizationError && error.code === 'invalid-unicode';
+ assert.throws(() => visibleStateCanonicalJson(state), isInvalidUnicode);
+ await assert.rejects(clientResultCanonicalBytes({ ...fixture.vectors[0].input, visible_state: state }), isInvalidUnicode);
+ }
+});
+
+test('injected SHA256 reproduces golden bytes without a WebCrypto dependency', async () => {
+ let calls = 0;
+ const sha256 = async bytes => { calls++; return new Uint8Array(createHash('sha256').update(bytes).digest()); };
+ for (const vector of fixture.vectors) {
+ assert.equal(await visibleStateDigest(vector.input.visible_state, sha256), vector.visible_state_sha256);
+ assert.equal(Buffer.from(await clientResultCanonicalBytes(vector.input, sha256)).toString('utf8'), vector.canonical_bytes);
+ }
+ assert.equal(calls, fixture.vectors.length * 2);
+});
diff --git a/clients/typescript/fractal-client/index.ts b/clients/typescript/fractal-client/index.ts
index 896488ca4e6..afc4c587448 100644
--- a/clients/typescript/fractal-client/index.ts
+++ b/clients/typescript/fractal-client/index.ts
@@ -2,3 +2,5 @@ export * from './Models.generated.ts';
export * from './Client.generated.ts';
export * from './errors.ts';
export * from './subagent-drivers.ts';
+export { CLIENT_RESULT_PURPOSE, CLIENT_RESULT_SIGNED_FIELDS, ClientResultCanonicalizationError, defaultWebCryptoSha256, visibleStateCanonicalJson, visibleStateDigest, clientResultCanonicalBytes } from './ClientControl.generated.ts';
+export type { ClientControlSha256 } from './ClientControl.generated.ts';
diff --git a/clients/typescript/fractal-client/package.json b/clients/typescript/fractal-client/package.json
index c7ec3e82f32..c5895ed3e32 100644
--- a/clients/typescript/fractal-client/package.json
+++ b/clients/typescript/fractal-client/package.json
@@ -5,8 +5,8 @@
"license": "MIT",
"type": "module",
"scripts": {
- "test": "node --test contract.test.cjs schema.test.cjs fabric.test.cjs",
- "test:contract": "node --test contract.test.cjs",
+ "test": "node --test contract.test.cjs schema.test.cjs fabric.test.cjs client-control.test.cjs",
+ "test:contract": "node --test contract.test.cjs client-control.test.cjs",
"test:schema": "node --test schema.test.cjs",
"typecheck": "pnpm run typecheck:client && pnpm run typecheck:schema",
"typecheck:client": "tsc -p tsconfig.json",
diff --git a/clients/typescript/fractal-client/schema.test.cjs b/clients/typescript/fractal-client/schema.test.cjs
index b87ca5bc567..fde040b6049 100644
--- a/clients/typescript/fractal-client/schema.test.cjs
+++ b/clients/typescript/fractal-client/schema.test.cjs
@@ -1335,3 +1335,35 @@ test('notice fixtures preserve rich Options, identifiers and lossless timestamps
// Existing timestamp annotations retain the established DateTime.Utc contract.
assert.equal(DateTime.toEpochMillis(Rich.decodeUnknownSync(Rich.Timestamp)(wire)), Date.parse(wire));
});
+
+test('commands Resource and CollectionFrame decode shared lifecycle vectors', async () => {
+ const [, Rich] = await modules;
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, '../../../fixtures/clients/client-control-lifecycle-v1.json'), 'utf8'));
+ for (const row of fixture.resources) {
+ const decoded = Rich.decodeUnknownSync(Rich.Resource, 'strict')(row);
+ assert.equal(decoded.kind, 'client-control-grant-lifecycle');
+ assert.equal(decoded.grant_id, row.grant_id);
+ assert.equal(decoded.state, row.state);
+ assert.equal(decoded.sequence, row.sequence);
+ }
+ for (const frame of fixture.frames) {
+ const decoded = Rich.decodeUnknownSync(Rich.CollectionFrame, 'strict')(frame);
+ assert.equal(decoded.collection, 'commands');
+ const rows = decoded.items ?? decoded.upserts;
+ assert.equal(rows[0].kind, 'client-control-grant-lifecycle');
+ }
+ for (const trace of fixture.traces) {
+ for (const step of trace.steps) {
+ if (step.expected === 'refuse-invalid-frame') {
+ assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')(step.resource));
+ assert.throws(() => Rich.decodeUnknownSync(Rich.CollectionFrame, 'strict')({ ...fixture.frames[0], items: [step.resource] }));
+ }
+ }
+ }
+ for (const sequence of [0, Number.MAX_SAFE_INTEGER + 1]) {
+ assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], sequence }));
+ }
+ assert.throws(() => Rich.decodeUnknownSync(Rich.Resource, 'strict')({ ...fixture.resources[0], state: 'future-state' }));
+});
diff --git a/clients/typescript/fractal-scenarios/src/kit/fold.ts b/clients/typescript/fractal-scenarios/src/kit/fold.ts
index 0c1c7a36200..836ca82a980 100644
--- a/clients/typescript/fractal-scenarios/src/kit/fold.ts
+++ b/clients/typescript/fractal-scenarios/src/kit/fold.ts
@@ -11,7 +11,7 @@ const upsert = (list: readonly A[], value: A): A[] => {
}
/** Complete client-v0 known-kind set; unsupported collections must not become future resources. */
-const COLLECTION_OF_KIND: Record = {
+const COLLECTION_OF_KIND: Record = {
agent: 'agents',
runtime: 'runtimes',
machine: 'machines',
@@ -28,6 +28,7 @@ const COLLECTION_OF_KIND: Record = {
lane: 'lanes',
device: 'devices',
'device-command': 'deviceCommands',
+ 'client-control-grant-lifecycle': null, // Control metadata is not visible scenario state.
operation: 'operations',
history: 'history',
session: 'sessions',
@@ -41,9 +42,11 @@ const COLLECTION_OF_KIND: Record = {
}
const applyChanges = (state: S, upserts: readonly WireResource[], removes: readonly string[], order: readonly string[] | undefined): S => {
+ if (upserts.length > 0 && removes.length === 0 && upserts.every((value) => value.kind === 'client-control-grant-lifecycle')) return state
const next = { ...state } as Record
for (const value of upserts) {
const key = Object.hasOwn(COLLECTION_OF_KIND, value.kind) ? COLLECTION_OF_KIND[value.kind as Resource['kind']] : undefined
+ if (key === null) continue
if (key === undefined) next.resources = upsert((next.resources ?? []) as Keyed[], value)
else {
if (!Array.isArray(next[key])) throw new Error(`a ${value.kind} upsert has no place in this slice`)
diff --git a/clients/typescript/fractal-scenarios/test/inventories.test.ts b/clients/typescript/fractal-scenarios/test/inventories.test.ts
index 3064f440587..4ac12381284 100644
--- a/clients/typescript/fractal-scenarios/test/inventories.test.ts
+++ b/clients/typescript/fractal-scenarios/test/inventories.test.ts
@@ -84,4 +84,27 @@ describe('canonical inventories', () => {
expect(nextRoster.state.devices[0]).toEqual(device)
expect(nextDetails.state.launches[0]).toEqual(launch)
})
+
+ it('does not fold control lifecycle metadata into visible scenario state', () => {
+ const { roster } = loadWorld('fleet-mid-refactor', { now: ANCHOR_MS }).slices
+ for (const state of ['started', 'ended', 'revoked'] as const) {
+ const lifecycle = {
+ id: 'client-control-grant/scenario-fixture/lifecycle', kind: 'client-control-grant-lifecycle' as const,
+ revision: '1', updated_at: new Date(ANCHOR_MS).toISOString(),
+ grant_id: 'client-control-grant/scenario-fixture', run_id: 'mission-run/scenario-fixture',
+ device: 'device/scenario-fixture', session: 'client-session/scenario-fixture',
+ state, reason: state === 'started' ? 'granted' : state === 'ended' ? 'run-ended' : 'revoked', sequence: 1,
+ }
+ const next = foldSlice({ ...roster, timeline: [{
+ _tag: 'changes', at_ms: 1, store: 1, upserts: [lifecycle], removes: [], order: [lifecycle.id],
+ }] }, 1)
+ expect(next.state).toEqual(roster.state)
+ const device = { ...roster.state.devices[0]!, revision: 'device-2' }
+ const mixed = foldSlice({ ...roster, timeline: [{
+ _tag: 'changes', at_ms: 1, store: 1, upserts: [lifecycle, device], removes: [],
+ }] }, 1)
+ expect(mixed.state.devices[0]).toEqual(device)
+ expect(mixed.state.resources).toEqual(roster.state.resources)
+ }
+ })
})
diff --git a/crates/fractal-client-codegen/src/main.rs b/crates/fractal-client-codegen/src/main.rs
index 516ed9a5bd0..af0ef57dc8f 100644
--- a/crates/fractal-client-codegen/src/main.rs
+++ b/crates/fractal-client-codegen/src/main.rs
@@ -23,6 +23,14 @@ fn main() -> Result<()> {
.skip(1)
.any(|argument| argument == "--check");
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
+ for (path, template) in [
+ ("crates/fractal-schema/src/client_control.rs", include_str!("../templates/client_control.rs.in")),
+ ("clients/typescript/fractal-client/ClientControl.generated.ts", include_str!("../templates/ClientControl.ts.in")),
+ ("clients/swift/FractalClient/Sources/FractalClient/ClientControl.generated.swift", include_str!("../templates/ClientControl.swift.in")),
+ ("fixtures/clients/client-control-v1.golden.json", include_str!("../templates/client-control-v1.golden.json.in")),
+ ] {
+ output(&root.join(path), template, check)?;
+ }
let operations_path = root.join("docs/st3/client-v0/schemas/operations.json");
let schema_path = root.join("docs/st3/client-v0/schemas/client-v0.schema.json");
let operations_bytes = std::fs::read(&operations_path).context("read operations manifest")?;
@@ -752,7 +760,10 @@ fn validate_model(
let command_body = definition == "DeviceCommand" && matches!(property.as_str(), "command_kind" | "args")
&& rust_block.contains("pub command: DeviceCommandBody")
&& rust.contains("#[serde(tag = \"command_kind\", content = \"args\")]");
- if !rust_block.contains(&format!("pub {rust_field}:")) && !rust_discriminated_timeline && !command_body {
+ let lifecycle_field = definition == "ClientControlGrantLifecycle"
+ && rust_block.contains("pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent")
+ && include_str!("../templates/client_control.rs.in").contains(&format!("pub {rust_field}:"));
+ if !rust_block.contains(&format!("pub {rust_field}:")) && !rust_discriminated_timeline && !command_body && !lifecycle_field {
bail!("Rust `{rust_name}` does not model schema field `{property}`");
}
let swift_field = match property.as_str() {
diff --git a/crates/fractal-client-codegen/templates/Client.swift.in b/crates/fractal-client-codegen/templates/Client.swift.in
index cf0251cacd9..3a84628d3f2 100644
--- a/crates/fractal-client-codegen/templates/Client.swift.in
+++ b/crates/fractal-client-codegen/templates/Client.swift.in
@@ -295,6 +295,7 @@ public actor FractalClient {
/// Issue one expiring foreground command, without durable receipt or replay.
public func deviceCommand(_ command: DeviceCommandRequest) async throws -> Envelope { try await post("v1/client/device-commands", command) }
public func deviceResult(_ result: DeviceResult) async throws -> Envelope { try await post("v1/client/device-results", result) }
+ public func clientTestRevoke(device: String) async throws -> JSONValue { try await post("v1/client/test-clients/revoke", ["device": device]) }
public func beginPairing(deviceName: String, personID: String) async throws -> Envelope { try await post("v1/client/pairings", PersonPairingBegin(apiVersion: fractalClientAPIVersion, deviceName: deviceName, personID: personID)) }
public func beginPairing(_ request: PairingBegin) async throws -> Envelope { try await post("v1/client/pairings", request) }
diff --git a/crates/fractal-client-codegen/templates/Client.ts.in b/crates/fractal-client-codegen/templates/Client.ts.in
index ab5e2df734b..904d8385950 100644
--- a/crates/fractal-client-codegen/templates/Client.ts.in
+++ b/crates/fractal-client-codegen/templates/Client.ts.in
@@ -486,6 +486,10 @@ export class FractalClient {
return this.request('POST', '/v1/client/device-results', result, undefined, undefined, requestOptions);
}
+ async clientTestRevoke(device: string, requestOptions: RequestOptions = {}): Promise {
+ return this.request('POST', '/v1/client/test-clients/revoke', { device }, undefined, undefined, requestOptions);
+ }
+
async beginPairing(request: PairingBegin, requestOptions: RequestOptions = {}): Promise> {
return this.request('POST', '/v1/client/pairings', request, undefined, undefined, requestOptions);
}
diff --git a/crates/fractal-client-codegen/templates/ClientControl.swift.in b/crates/fractal-client-codegen/templates/ClientControl.swift.in
new file mode 100644
index 00000000000..4f6ed189984
--- /dev/null
+++ b/crates/fractal-client-codegen/templates/ClientControl.swift.in
@@ -0,0 +1,120 @@
+// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT.
+// Source: crates/fractal-client-codegen/templates/ClientControl.swift.in.
+import Foundation
+import CryptoKit
+
+public let clientResultPurpose = "client.device-result.v1"
+public let clientResultSignedFields = ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state_sha256"]
+public enum ClientControlRefusal: String, Codable, Sendable {
+ case deviceNotTestClient = "device-not-test-client", grantMissing = "grant-missing", grantExpired = "grant-expired"
+ case screenNotGranted = "screen-not-granted", verbNotGranted = "verb-not-granted", budgetExhausted = "budget-exhausted", actorNotGranted = "actor-not-granted"
+}
+public struct ClientTestEnrollment: Codable, Sendable {
+ public var device: String; public var kind: String; public var enrolledBy: String
+ public var consentRef: String; public var packetDigest: String; public var host: String; public var instance: String
+ public var pairingSubject: String; public var publicKey: String; public var session: String
+}
+public struct ClientTestRevocation: Codable, Sendable {
+ public var device: String; public var enrollmentId: String; public var revokedBy: String
+}
+public struct ClientCommandReceipt: Codable, Sendable {
+ public var commandId: String; public var terminalState: DeviceCommandOutcome
+ public var issuer: String; public var device: String; public var session: String?
+ public var grantId: String?; public var runId: String?; public var commandKind: String
+ public var screen: String?; public var reason: ClientControlRefusal?
+ public var verifiedResultSignature: DeviceSignature?; public var signedResult: ClientResultBinding?
+ private enum CodingKeys: String, CodingKey { case commandId, terminalState, issuer, device, session, grantId, runId, commandKind, screen, reason, verifiedResultSignature, signedResult }
+ public func encode(to encoder: Encoder) throws {
+ var box = encoder.container(keyedBy: CodingKeys.self)
+ try box.encode(commandId, forKey: .commandId); try box.encode(terminalState, forKey: .terminalState)
+ try box.encode(issuer, forKey: .issuer); try box.encode(device, forKey: .device); try box.encodeIfPresent(session, forKey: .session)
+ try box.encodeIfPresent(grantId, forKey: .grantId); try box.encodeIfPresent(runId, forKey: .runId); try box.encode(commandKind, forKey: .commandKind)
+ try box.encodeIfPresent(screen, forKey: .screen); try box.encodeIfPresent(reason, forKey: .reason)
+ if let verifiedResultSignature { try box.encode(verifiedResultSignature, forKey: .verifiedResultSignature) }
+ else { try box.encodeNil(forKey: .verifiedResultSignature) }
+ if let signedResult { try box.encode(signedResult, forKey: .signedResult) }
+ else { try box.encodeNil(forKey: .signedResult) }
+ }
+}
+public struct ClientVisibleItem: Codable, Sendable, Equatable {
+ public var id: String; public var title: String; public var body: String
+ public init(id: String, title: String, body: String) { self.id = id; self.title = title; self.body = body }
+}
+public struct ClientVisibleState: Codable, Sendable, Equatable {
+ public var route: String; public var screen: String; public var visible_item_ids: [String]; public var opened_item: ClientVisibleItem?
+ public init(route: String, screen: String, visible_item_ids: [String], opened_item: ClientVisibleItem?) {
+ self.route = route; self.screen = screen; self.visible_item_ids = visible_item_ids; self.opened_item = opened_item
+ }
+ private enum CodingKeys: String, CodingKey { case route, screen, visible_item_ids, opened_item }
+ public func encode(to encoder: Encoder) throws {
+ var box = encoder.container(keyedBy: CodingKeys.self)
+ try box.encode(route, forKey: .route); try box.encode(screen, forKey: .screen)
+ try box.encode(visible_item_ids, forKey: .visible_item_ids)
+ if let opened_item { try box.encode(opened_item, forKey: .opened_item) }
+ else { try box.encodeNil(forKey: .opened_item) }
+ }
+}
+public struct ClientResultBinding: Codable, Sendable, Equatable {
+ public var purpose: String; public var command_kind: String; public var device: String; public var session: String
+ public var grant_id: String; public var run_id: String; public var command_id: String; public var outcome: String
+ public var error_code: String?; public var visible_state: ClientVisibleState
+ public init(purpose: String = clientResultPurpose, command_kind: String, device: String, session: String, grant_id: String, run_id: String, command_id: String, outcome: String, error_code: String?, visible_state: ClientVisibleState) {
+ self.purpose = purpose; self.command_kind = command_kind; self.device = device; self.session = session
+ self.grant_id = grant_id; self.run_id = run_id; self.command_id = command_id; self.outcome = outcome
+ self.error_code = error_code; self.visible_state = visible_state
+ }
+ private enum CodingKeys: String, CodingKey {
+ case purpose, command_kind, device, session, grant_id, run_id, command_id, outcome, error_code, visible_state
+ }
+ public func encode(to encoder: Encoder) throws {
+ var box = encoder.container(keyedBy: CodingKeys.self)
+ try box.encode(purpose, forKey: .purpose); try box.encode(command_kind, forKey: .command_kind)
+ try box.encode(device, forKey: .device); try box.encode(session, forKey: .session)
+ try box.encode(grant_id, forKey: .grant_id); try box.encode(run_id, forKey: .run_id)
+ try box.encode(command_id, forKey: .command_id); try box.encode(outcome, forKey: .outcome)
+ if let error_code { try box.encode(error_code, forKey: .error_code) }
+ else { try box.encodeNil(forKey: .error_code) }
+ try box.encode(visible_state, forKey: .visible_state)
+ }
+}
+public struct ClientControlGrant: Codable, Sendable, Equatable {
+ public var grant_id: String; public var run_id: String; public var device: String; public var session: String; public var actor_agent: String
+ public var verbs: [String]; public var screens: [String]; public var max_commands: UInt64; public var expires_at: String; public var state: String
+ public var item_id: String?
+}
+private func clientResultQuote(_ text: String) -> String {
+ var result = "\""
+ for scalar in text.unicodeScalars {
+ switch scalar.value {
+ case 0x22: result += "\\\""
+ case 0x5c: result += "\\\\"
+ case 8: result += "\\b"
+ case 9: result += "\\t"
+ case 10: result += "\\n"
+ case 12: result += "\\f"
+ case 13: result += "\\r"
+ case 0...31: result += String(format: "\\u%04x", scalar.value)
+ default: result.unicodeScalars.append(scalar)
+ }
+ }
+ return result + "\""
+}
+/// RFC 8785 JCS for the generated closed string-only visible-state schema.
+public func visibleStateCanonicalJson(_ state: ClientVisibleState) -> String {
+ let item = state.opened_item.map { "{\"body\":" + clientResultQuote($0.body) + ",\"id\":" + clientResultQuote($0.id) + ",\"title\":" + clientResultQuote($0.title) + "}" } ?? "null"
+ return "{\"opened_item\":" + item + ",\"route\":" + clientResultQuote(state.route) + ",\"screen\":" + clientResultQuote(state.screen) + ",\"visible_item_ids\":[" + state.visible_item_ids.map(clientResultQuote).joined(separator: ",") + "]}"
+}
+public func visibleStateDigest(_ state: ClientVisibleState) -> String {
+ SHA256.hash(data: Data(visibleStateCanonicalJson(state).utf8)).map { String(format: "%02x", $0) }.joined()
+}
+/// Sign with P256.Signing.PrivateKey.signature(for:).rawRepresentation (r||s).
+/// This domain is never message.send authorization. Enforce active grant first.
+public func clientResultCanonicalBytes(_ binding: ClientResultBinding) -> Data {
+ let fields: [(String, String?)] = [
+ ("purpose", binding.purpose), ("command_kind", binding.command_kind), ("device", binding.device),
+ ("session", binding.session), ("grant_id", binding.grant_id), ("run_id", binding.run_id),
+ ("command_id", binding.command_id), ("outcome", binding.outcome), ("error_code", binding.error_code),
+ ("visible_state_sha256", visibleStateDigest(binding.visible_state))
+ ]
+ return Data((["smallclaims-claim-fields-v1"] + fields.map { $0.0 + "=" + ($0.1.map(clientResultQuote) ?? "null") }).joined(separator: "\n").utf8)
+}
diff --git a/crates/fractal-client-codegen/templates/ClientControl.ts.in b/crates/fractal-client-codegen/templates/ClientControl.ts.in
new file mode 100644
index 00000000000..9024c9c6c24
--- /dev/null
+++ b/crates/fractal-client-codegen/templates/ClientControl.ts.in
@@ -0,0 +1,60 @@
+// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT.
+// Source: crates/fractal-client-codegen/templates/ClientControl.ts.in.
+export const CLIENT_RESULT_PURPOSE = 'client.device-result.v1';
+export const CLIENT_RESULT_SIGNED_FIELDS = ['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code', 'visible_state_sha256'] as const;
+export interface ClientVisibleItem { id: string; title: string; body: string }
+export interface ClientVisibleState { route: string; screen: string; visible_item_ids: string[]; opened_item: ClientVisibleItem | null }
+export interface ClientResultBinding {
+ purpose: string; command_kind: string; device: string; session: string;
+ grant_id: string; run_id: string; command_id: string; outcome: string;
+ error_code: string | null; visible_state: ClientVisibleState;
+}
+export interface ClientControlGrant {
+ grant_id: string; run_id: string; device: string; session: string; actor_agent: string;
+ verbs: string[]; screens: string[]; max_commands: number; expires_at: string; state: string;
+ item_id?: string;
+}
+export class ClientResultCanonicalizationError extends Error {
+ readonly code = 'invalid-unicode' as const;
+ constructor() {
+ super('Invalid Unicode in client result');
+ this.name = 'ClientResultCanonicalizationError';
+ }
+}
+function quote(value: string): string {
+ // RFC 8785 requires rejection of lone surrogates rather than replacement.
+ for (let i = 0; i < value.length; i++) {
+ const c = value.charCodeAt(i);
+ if (c >= 0xd800 && c <= 0xdbff) {
+ const next = value.charCodeAt(++i);
+ if (!(next >= 0xdc00 && next <= 0xdfff)) throw new ClientResultCanonicalizationError();
+ } else if (c >= 0xdc00 && c <= 0xdfff) throw new ClientResultCanonicalizationError();
+ }
+ return JSON.stringify(value);
+}
+/** RFC 8785 JCS for the generated closed string-only visible-state schema. */
+export function visibleStateCanonicalJson(state: ClientVisibleState): string {
+ const item = state.opened_item;
+ return '{"opened_item":' + (item === null ? 'null' : '{"body":' + quote(item.body) + ',"id":' + quote(item.id) + ',"title":' + quote(item.title) + '}')
+ + ',"route":' + quote(state.route) + ',"screen":' + quote(state.screen)
+ + ',"visible_item_ids":[' + state.visible_item_ids.map(quote).join(',') + ']}';
+}
+export type ClientControlSha256 = (bytes: Uint8Array) => Promise;
+export const defaultWebCryptoSha256: ClientControlSha256 = async bytes =>
+ new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(bytes).buffer));
+export async function visibleStateDigest(state: ClientVisibleState, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise {
+ const digest = await sha256(new TextEncoder().encode(visibleStateCanonicalJson(state)));
+ return Array.from(digest, b => b.toString(16).padStart(2, '0')).join('');
+}
+/** Sign using ECDSA P-256/SHA-256, IEEE P1363 r||s (64 bytes), base64url without padding.
+ * Never use this helper as message.send authorization. Enforce active grant first. */
+export async function clientResultCanonicalBytes(binding: ClientResultBinding, sha256: ClientControlSha256 = defaultWebCryptoSha256): Promise {
+ const digest = await visibleStateDigest(binding.visible_state, sha256);
+ const fields: [string, string | null][] = [
+ ['purpose', binding.purpose], ['command_kind', binding.command_kind], ['device', binding.device],
+ ['session', binding.session], ['grant_id', binding.grant_id], ['run_id', binding.run_id],
+ ['command_id', binding.command_id], ['outcome', binding.outcome], ['error_code', binding.error_code],
+ ['visible_state_sha256', digest],
+ ];
+ return new TextEncoder().encode(['smallclaims-claim-fields-v1', ...fields.map(([k, v]) => k + '=' + (v === null ? 'null' : quote(v)))].join('\n'));
+}
diff --git a/crates/fractal-client-codegen/templates/Models.swift.in b/crates/fractal-client-codegen/templates/Models.swift.in
index f67d5ffb1f3..c4366d073bb 100644
--- a/crates/fractal-client-codegen/templates/Models.swift.in
+++ b/crates/fractal-client-codegen/templates/Models.swift.in
@@ -791,20 +791,23 @@ public struct DeviceCommandRequest: Codable, Sendable {
public let apiVersion, device: String
public let command: PhoneCommand
public let ttlMS: UInt64?
- public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, apiVersion: String = "st3.client.v0") {
- self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS
+ public let grantID: String?
+ public init(device: String, command: PhoneCommand, ttlMS: UInt64? = nil, grantID: String? = nil, apiVersion: String = "st3.client.v0") {
+ self.apiVersion = apiVersion; self.device = device; self.command = command; self.ttlMS = ttlMS; self.grantID = grantID
}
- private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms" }
+ private enum Keys: String, CodingKey { case apiVersion = "api_version", device, ttlMS = "ttl_ms", grantID = "grant_id" }
public init(from decoder: Decoder) throws {
let box = try decoder.container(keyedBy: Keys.self)
apiVersion = try box.decode(String.self, forKey: .apiVersion)
device = try box.decode(String.self, forKey: .device)
ttlMS = try box.decodeIfPresent(UInt64.self, forKey: .ttlMS)
+ grantID = try box.decodeIfPresent(String.self, forKey: .grantID)
command = try PhoneCommand(from: decoder)
}
public func encode(to encoder: Encoder) throws {
var box = encoder.container(keyedBy: Keys.self)
try box.encode(apiVersion, forKey: .apiVersion); try box.encode(device, forKey: .device)
+ try box.encodeIfPresent(grantID, forKey: .grantID)
try box.encodeIfPresent(ttlMS, forKey: .ttlMS); try command.encode(to: encoder)
}
}
@@ -813,10 +816,12 @@ public struct DeviceResult: Codable, Sendable {
public let outcome: DeviceCommandOutcome
public let result: JSONValue?
public let errorCode: String?
- public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil) {
- self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode
+ public let binding: ClientResultBinding?
+ public let signature: DeviceSignature?
+ public init(commandID: String, outcome: DeviceCommandOutcome, result: JSONValue? = nil, errorCode: String? = nil, binding: ClientResultBinding? = nil, signature: DeviceSignature? = nil) {
+ self.commandID = commandID; self.outcome = outcome; self.result = result; self.errorCode = errorCode; self.binding = binding; self.signature = signature
}
- enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code" }
+ enum CodingKeys: String, CodingKey { case commandID = "command_id", outcome, result, errorCode = "error_code", binding, signature }
}
public struct DeviceCommand: Codable, Sendable, Identifiable {
public let id, kind, revision, updatedAt, deviceID, sessionActor, issuedBy, issuedAt, expiresAt: String
@@ -824,9 +829,14 @@ public struct DeviceCommand: Codable, Sendable, Identifiable {
public let state: DeviceCommandState
public let result: JSONValue?
public let errorCode: String?
+ public let grantID, runID: String?
+ public let controlGrant: ClientControlGrant?
+ public let receiptClaimID: String?
private enum Keys: String, CodingKey {
case id, kind, revision, updatedAt = "updated_at", deviceID = "device_id", sessionActor = "session_actor"
case issuedBy = "issued_by", issuedAt = "issued_at", expiresAt = "expires_at", state, result, errorCode = "error_code"
+ case grantID = "grant_id", runID = "run_id", controlGrant = "control_grant"
+ case receiptClaimID = "receipt_claim_id"
}
public init(from decoder: Decoder) throws {
let box = try decoder.container(keyedBy: Keys.self)
@@ -836,6 +846,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable {
issuedBy = try box.decode(String.self, forKey: .issuedBy); issuedAt = try box.decode(String.self, forKey: .issuedAt)
expiresAt = try box.decode(String.self, forKey: .expiresAt); state = try box.decode(DeviceCommandState.self, forKey: .state)
result = try box.decodeIfPresent(JSONValue.self, forKey: .result); errorCode = try box.decodeIfPresent(String.self, forKey: .errorCode)
+ grantID = try box.decodeIfPresent(String.self, forKey: .grantID); runID = try box.decodeIfPresent(String.self, forKey: .runID)
+ controlGrant = try box.decodeIfPresent(ClientControlGrant.self, forKey: .controlGrant)
+ receiptClaimID = try box.decodeIfPresent(String.self, forKey: .receiptClaimID)
command = try PhoneCommand(from: decoder, resource: true)
}
public func encode(to encoder: Encoder) throws {
@@ -846,6 +859,9 @@ public struct DeviceCommand: Codable, Sendable, Identifiable {
try box.encode(issuedBy, forKey: .issuedBy); try box.encode(issuedAt, forKey: .issuedAt)
try box.encode(expiresAt, forKey: .expiresAt); try box.encode(state, forKey: .state)
try box.encodeIfPresent(result, forKey: .result); try box.encodeIfPresent(errorCode, forKey: .errorCode)
+ try box.encodeIfPresent(grantID, forKey: .grantID); try box.encodeIfPresent(runID, forKey: .runID)
+ try box.encodeIfPresent(controlGrant, forKey: .controlGrant)
+ try box.encodeIfPresent(receiptClaimID, forKey: .receiptClaimID)
try command.encode(to: encoder, resource: true)
}
}
@@ -994,11 +1010,50 @@ public enum HarnessInvalidAnswerReason: String, Codable, Sendable { case unknown
public struct HarnessInvalidAnswerDetails: Codable, Sendable { public let reason: HarnessInvalidAnswerReason }
public enum HarnessNotAnswerableReason: String, Codable, Sendable { case adapterMissing = "adapter_missing", adapterUnavailable = "adapter_unavailable" }
public struct HarnessNotAnswerableDetails: Codable, Sendable { public let reason: HarnessNotAnswerableReason }
+public enum ClientControlGrantLifecycleState: String, Codable, Sendable { case started, ended, revoked }
+public struct ClientControlGrantLifecycleResource: Codable, Sendable, Identifiable {
+ public let id, kind, revision, updatedAt, grantID, runID, device, session: String
+ public let itemID: String?
+ public let state: ClientControlGrantLifecycleState
+ public let reason: String
+ public let sequence: UInt64
+ enum CodingKeys: String, CodingKey {
+ case id, kind, revision, updatedAt = "updated_at", grantID = "grant_id", runID = "run_id", itemID = "item_id", device, session, state, reason, sequence
+ }
+ public init(from decoder: Decoder) throws {
+ let values = try decoder.container(keyedBy: CodingKeys.self)
+ id = try values.decode(String.self, forKey: .id)
+ kind = try values.decode(String.self, forKey: .kind)
+ revision = try values.decode(String.self, forKey: .revision)
+ updatedAt = try values.decode(String.self, forKey: .updatedAt)
+ grantID = try values.decode(String.self, forKey: .grantID)
+ runID = try values.decode(String.self, forKey: .runID)
+ device = try values.decode(String.self, forKey: .device)
+ session = try values.decode(String.self, forKey: .session)
+ itemID = try values.decodeIfPresent(String.self, forKey: .itemID)
+ state = try values.decode(ClientControlGrantLifecycleState.self, forKey: .state)
+ reason = try values.decode(String.self, forKey: .reason)
+ sequence = try values.decode(UInt64.self, forKey: .sequence)
+ guard sequence >= 1 && sequence <= 9_007_199_254_740_991 else {
+ throw DecodingError.dataCorruptedError(forKey: .sequence, in: values, debugDescription: "Lifecycle sequence must be a positive JSON-safe integer")
+ }
+ }
+}
+/// Existing commands snapshot/changes frames; lifecycle rows share Resource decoding.
+public struct ClientCommandsCollectionFrame: Codable, Sendable {
+ public let kind, id, collection: String
+ public let snapshot: Snapshot
+ public let items, upserts: [Resource]?
+ public let removes, order: [String]?
+ public let hasMore: Bool
+ enum CodingKeys: String, CodingKey { case kind, id, collection, snapshot, items, upserts, removes, order, hasMore = "has_more" }
+}
public enum Resource: Codable, Sendable, Identifiable {
case harnessQuestion(HarnessQuestionResource)
case connectedClient(ConnectedClientResource), usage(UsageResource)
case summary(SummaryResource)
case deviceCommand(DeviceCommand)
+ case clientControlGrantLifecycle(ClientControlGrantLifecycleResource)
case attention(AttentionResource), message(MessageResource), launch(LaunchResource), launchVariant(LaunchVariantResource), launchDecision(LaunchDecisionResource), launchApproval(LaunchApprovalResource), mission(MissionResource), work(WorkResource), agent(AgentResource), runtime(RuntimeResource), observer(ObserverResource), subscription(SubscriptionResource), lane(LaneResource), machine(MachineResource), device(DeviceResource), operation(OperationResource), history(HistoryResource), session(SessionResource), glass(GlassResource), ownedSet(OwnedSetResource)
/// A kind this client does not know, from a member newer than it: kept whole so an older
/// client skips it or shows it plainly instead of failing the page it came in.
@@ -1027,6 +1082,7 @@ public enum Resource: Codable, Sendable, Identifiable {
case "machine": self = .machine(try MachineResource(from: decoder))
case "device": self = .device(try DeviceResource(from: decoder))
case "device-command": self = .deviceCommand(try DeviceCommand(from: decoder))
+ case "client-control-grant-lifecycle": self = .clientControlGrantLifecycle(try ClientControlGrantLifecycleResource(from: decoder))
case "operation": self = .operation(try OperationResource(from: decoder))
case "history": self = .history(try HistoryResource(from: decoder))
case "session": self = .session(try SessionResource(from: decoder))
@@ -1058,6 +1114,7 @@ public enum Resource: Codable, Sendable, Identifiable {
case .machine(let v): try v.encode(to: encoder)
case .device(let v): try v.encode(to: encoder)
case .deviceCommand(let v): try v.encode(to: encoder)
+ case .clientControlGrantLifecycle(let v): try v.encode(to: encoder)
case .operation(let v): try v.encode(to: encoder)
case .history(let v): try v.encode(to: encoder)
case .session(let v): try v.encode(to: encoder)
@@ -1067,7 +1124,7 @@ public enum Resource: Codable, Sendable, Identifiable {
case .unknown(let v): try v.encode(to: encoder)
}
}
- public var id: String { switch self { case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } }
+ public var id: String { switch self { case .clientControlGrantLifecycle(let v): v.id; case .harnessQuestion(let v): v.id; case .connectedClient(let v): v.id; case .usage(let v): v.id; case .summary(let v): v.id; case .attention(let v): v.id; case .message(let v): v.id; case .launch(let v): v.id; case .launchVariant(let v): v.id; case .launchDecision(let v): v.id; case .launchApproval(let v): v.id; case .mission(let v): v.id; case .work(let v): v.id; case .agent(let v): v.id; case .runtime(let v): v.id; case .observer(let v): v.id; case .subscription(let v): v.id; case .lane(let v): v.id; case .machine(let v): v.id; case .device(let v): v.id; case .deviceCommand(let v): v.id; case .operation(let v): v.id; case .history(let v): v.id; case .session(let v): v.id; case .glass(let v): v.id; case .arrangement(let v): v.id; case .ownedSet(let v): v.id; case .unknown(let v): v.id } }
}
/// A resource of a kind this client does not model: its header, and every field as sent.
public struct UnknownResource: Codable, Sendable, Identifiable {
diff --git a/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in
new file mode 100644
index 00000000000..97bd57cb9ff
--- /dev/null
+++ b/crates/fractal-client-codegen/templates/client-control-v1.golden.json.in
@@ -0,0 +1,501 @@
+{
+ "_generated": "cargo run -p fractal-client-codegen; source templates/client-control-v1.golden.json.in; fixed PUBLIC TEST key scalar 1, nonce scalar 2, never use for enrollment",
+ "format": "client-control-v1",
+ "public_key_sec1_base64url": "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU",
+ "private_scalar_hex_for_tests": "0000000000000000000000000000000000000000000000000000000000000001",
+ "message_domain_bytes": "smallclaims-claim-fields-v1\nmessage/test\nmessage.sent\nperson/client-control-fixture\ncontent=\"hello\"",
+ "message_domain_signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXgRe11q295ks9iyv8QCIwcWwVU7WhRTUdQe_JG0MhGy1w",
+ "vectors": [
+ {
+ "name": "inbox",
+ "input": {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"step-run/ask-golden/ask\"]}",
+ "visible_state_sha256": "4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453",
+ "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453\"",
+ "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXiuu76h6230PGyTZfFujp1lxwC70zSTNzoxlic27FPzmg",
+ "negative_bindings": [
+ {
+ "purpose": "message.send",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/other",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/other",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/other",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.message.read",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/other",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/other",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "failed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": "tampered",
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/tampered",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ }
+ ]
+ },
+ {
+ "name": "unicode-opened-item",
+ "input": {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/β¨β©\",\"id\":\"step-run/ask-golden/ask\",\"title\":\"Seed Γ© π\"},\"route\":\"/attention/step-run/ask-golden/ask\",\"screen\":\"Attention\",\"visible_item_ids\":[\"step-run/ask-golden/ask\",\"message/Γ©π\"]}",
+ "visible_state_sha256": "2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3",
+ "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3\"",
+ "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXi45Ckt8qvn0tLGFBvQ7AEZ-86R5CQtnzYJQ5kea6paXQ",
+ "negative_bindings": [
+ {
+ "purpose": "message.send",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/other",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/other",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/other",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.message.read",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/other",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/other",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "failed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": "tampered",
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/tampered",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/crates/fractal-client-codegen/templates/client_control.rs.in b/crates/fractal-client-codegen/templates/client_control.rs.in
new file mode 100644
index 00000000000..c4fde6a50bb
--- /dev/null
+++ b/crates/fractal-client-codegen/templates/client_control.rs.in
@@ -0,0 +1,148 @@
+// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT.
+// Source: crates/fractal-client-codegen/templates/client_control.rs.in.
+//! Purpose-specific client results. This is not a message signing authorization.
+use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
+
+pub const CLIENT_RESULT_PURPOSE: &str = "client.device-result.v1";
+pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[
+ "purpose", "command_kind", "device", "session", "grant_id", "run_id",
+ "command_id", "outcome", "error_code", "visible_state_sha256",
+];
+#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum ClientControlGrantLifecycleState { Started, Ended, Revoked }
+/// Session-scoped payload carried only by the existing commands collection.
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+pub struct ClientControlGrantLifecycleEvent {
+ pub grant_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option,
+ pub run_id: String,
+ pub device: String,
+ pub session: String,
+ pub state: ClientControlGrantLifecycleState,
+ pub reason: String,
+ #[serde(deserialize_with = "deserialize_lifecycle_sequence")]
+ pub sequence: u64,
+}
+fn deserialize_lifecycle_sequence<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result {
+ let sequence = u64::deserialize(deserializer)?;
+ if !(1..=(1_u64 << 53) - 1).contains(&sequence) {
+ return Err(serde::de::Error::custom("lifecycle sequence must be a positive JSON-safe integer"));
+ }
+ Ok(sequence)
+}
+#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum ClientControlRefusal {
+ DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted,
+ VerbNotGranted, BudgetExhausted, ActorNotGranted,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct ClientTestEnrollment {
+ pub device: String, pub kind: String, pub enrolled_by: String,
+ pub consent_ref: String, pub packet_digest: String, pub host: String,
+ pub instance: String, pub pairing_subject: String, pub public_key: String, pub session: String,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct ClientTestRevocation {
+ pub device: String, pub enrollment_id: String, pub revoked_by: String,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientVisibleItem { pub id: String, pub title: String, pub body: String }
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientVisibleState {
+ pub route: String,
+ pub screen: String,
+ pub visible_item_ids: Vec,
+ pub opened_item: Option,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientResultBinding {
+ pub purpose: String,
+ pub command_kind: String,
+ pub device: String,
+ pub session: String,
+ pub grant_id: String,
+ pub run_id: String,
+ pub command_id: String,
+ pub outcome: String,
+ pub error_code: Option,
+ pub visible_state: ClientVisibleState,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientControlGrant {
+ pub grant_id: String,
+ pub run_id: String,
+ pub device: String,
+ pub session: String,
+ pub actor_agent: String,
+ pub verbs: Vec,
+ pub screens: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option,
+ pub max_commands: u64,
+ pub expires_at: String,
+ pub state: String,
+}
+/// RFC 8785 JCS for this closed, string-only schema. Keys are emitted in ASCII
+/// order explicitly, independent of serde_json's preserve_order feature.
+/// There are no numbers or arbitrary object keys in this type.
+pub fn visible_state_canonical_json(state: &ClientVisibleState) -> Result {
+ #[derive(Serialize)]
+ struct Item<'a> { body: &'a str, id: &'a str, title: &'a str }
+ #[derive(Serialize)]
+ struct State<'a> { opened_item: Option- >, route: &'a str, screen: &'a str, visible_item_ids: &'a [String] }
+ serde_json::to_string(&State {
+ opened_item: state.opened_item.as_ref().map(|item| Item { body: &item.body, id: &item.id, title: &item.title }),
+ route: &state.route, screen: &state.screen, visible_item_ids: &state.visible_item_ids,
+ })
+}
+pub fn visible_state_digest(state: &ClientVisibleState) -> Result {
+ Ok(hex::encode(Sha256::digest(visible_state_canonical_json(state)?.as_bytes())))
+}
+/// Purpose is the first fields-v1 field. Values are JSON, not raw lines, so
+/// embedded newlines cannot inject fields. No trailing newline is signed.
+/// Callers MUST enforce the purpose constant, enrolled key, grant and windows.
+pub fn client_result_canonical_bytes(binding: &ClientResultBinding) -> Result, serde_json::Error> {
+ let digest = visible_state_digest(&binding.visible_state)?;
+ let mut lines = vec!["smallclaims-claim-fields-v1".to_owned()];
+ for (name, value) in [
+ ("purpose", Some(binding.purpose.as_str())),
+ ("command_kind", Some(binding.command_kind.as_str())),
+ ("device", Some(binding.device.as_str())),
+ ("session", Some(binding.session.as_str())),
+ ("grant_id", Some(binding.grant_id.as_str())),
+ ("run_id", Some(binding.run_id.as_str())),
+ ("command_id", Some(binding.command_id.as_str())),
+ ("outcome", Some(binding.outcome.as_str())),
+ ("error_code", binding.error_code.as_deref()),
+ ("visible_state_sha256", Some(digest.as_str())),
+ ] { lines.push(format!("{name}={}", serde_json::to_string(&value)?)); }
+ Ok(lines.join("\n").into_bytes())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn shared_golden_vectors() {
+ let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap();
+ for vector in fixture["vectors"].as_array().unwrap() {
+ let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap();
+ assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap());
+ assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap());
+ let bytes = client_result_canonical_bytes(&binding).unwrap();
+ assert_eq!(bytes, vector["canonical_bytes"].as_str().unwrap().as_bytes());
+ for mutation in vector["negative_bindings"].as_array().unwrap() {
+ let changed: ClientResultBinding = serde_json::from_value(mutation.clone()).unwrap();
+ assert_ne!(bytes, client_result_canonical_bytes(&changed).unwrap());
+ }
+ }
+ }
+}
diff --git a/crates/fractal-client-codegen/templates/generated.rs.in b/crates/fractal-client-codegen/templates/generated.rs.in
index a2a87f21f7d..8e29251ab9b 100644
--- a/crates/fractal-client-codegen/templates/generated.rs.in
+++ b/crates/fractal-client-codegen/templates/generated.rs.in
@@ -2856,6 +2856,7 @@ pub enum Resource {
Machine(Machine),
Device(Device),
DeviceCommand(DeviceCommand),
+ ClientControlGrantLifecycle(ClientControlGrantLifecycle),
Operation(Operation),
History(History),
Session(Session),
@@ -2891,6 +2892,7 @@ pub const KNOWN_RESOURCE_KINDS: &[&str] = &[
"machine",
"device",
"device-command",
+ "client-control-grant-lifecycle",
"operation",
"history",
"session",
@@ -2960,6 +2962,7 @@ impl Resource {
Self::Machine(v) => &v.header,
Self::Device(v) => &v.header,
Self::DeviceCommand(v) => &v.header,
+ Self::ClientControlGrantLifecycle(v) => &v.header,
Self::Operation(v) => &v.header,
Self::History(v) => &v.header,
Self::Session(v) => &v.header,
@@ -5263,6 +5266,7 @@ pub enum PhoneCommand {
pub struct DeviceCommandRequest {
pub api_version: String,
pub device: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option,
#[serde(flatten)] pub command: PhoneCommand,
#[serde(default, skip_serializing_if = "Option::is_none")] pub ttl_ms: Option,
}
@@ -5286,6 +5290,24 @@ pub struct DeviceResult {
pub outcome: DeviceCommandOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option,
#[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub binding: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub signature: Option,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct ClientCommandReceipt {
+ pub command_id: String,
+ pub terminal_state: DeviceCommandOutcome,
+ pub issuer: String,
+ pub device: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub session: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option,
+ pub command_kind: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub screen: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub reason: Option,
+ pub verified_result_signature: Option,
+ pub signed_result: Option,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct DeviceCommand {
@@ -5296,7 +5318,21 @@ pub struct DeviceCommand {
pub issued_by: String,
pub issued_at: String,
pub expires_at: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub grant_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub run_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub control_grant: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub receipt_claim_id: Option,
pub state: DeviceCommandState,
#[serde(default, skip_serializing_if = "Option::is_none")] pub result: Option,
#[serde(default, skip_serializing_if = "Option::is_none")] pub error_code: Option,
}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+pub struct ClientControlGrantLifecycle {
+ #[serde(flatten)] pub header: ResourceHeader,
+ #[serde(flatten)] pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent,
+}
+impl std::ops::Deref for ClientControlGrantLifecycle {
+ type Target = fractal_schema::client_control::ClientControlGrantLifecycleEvent;
+ fn deref(&self) -> &Self::Target { &self.lifecycle }
+}
diff --git a/crates/fractal-client-codegen/templates/lib.rs.in b/crates/fractal-client-codegen/templates/lib.rs.in
index cb69c4c2fae..70d437ebf3d 100644
--- a/crates/fractal-client-codegen/templates/lib.rs.in
+++ b/crates/fractal-client-codegen/templates/lib.rs.in
@@ -17,6 +17,7 @@ pub mod propagation;
pub mod scenarios;
pub use contract::*;
pub use generated::*;
+pub use fractal_schema::client_control::*;
mod identities;
pub use identities::*;
@@ -2023,6 +2024,10 @@ impl Client {
self.post("/v1/client/device-results", result).await
}
+ pub async fn client_test_revoke(&self, device: &str) -> Result {
+ self.post("/v1/client/test-clients/revoke", &serde_json::json!({"device": device})).await
+ }
+
pub async fn pairing_begin(
&self,
request: &PairingBegin,
diff --git a/crates/fractal-client/src/generated.rs b/crates/fractal-client/src/generated.rs
index c26061f4283..e61201fecdc 100644
--- a/crates/fractal-client/src/generated.rs
+++ b/crates/fractal-client/src/generated.rs
@@ -2857,6 +2857,7 @@ pub enum Resource {
Machine(Machine),
Device(Device),
DeviceCommand(DeviceCommand),
+ ClientControlGrantLifecycle(ClientControlGrantLifecycle),
Operation(Operation),
History(History),
Session(Session),
@@ -2892,6 +2893,7 @@ pub const KNOWN_RESOURCE_KINDS: &[&str] = &[
"machine",
"device",
"device-command",
+ "client-control-grant-lifecycle",
"operation",
"history",
"session",
@@ -2961,6 +2963,7 @@ impl Resource {
Self::Machine(v) => &v.header,
Self::Device(v) => &v.header,
Self::DeviceCommand(v) => &v.header,
+ Self::ClientControlGrantLifecycle(v) => &v.header,
Self::Operation(v) => &v.header,
Self::History(v) => &v.header,
Self::Session(v) => &v.header,
@@ -6175,6 +6178,8 @@ pub enum PhoneCommand {
pub struct DeviceCommandRequest {
pub api_version: String,
pub device: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub grant_id: Option,
#[serde(flatten)]
pub command: PhoneCommand,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -6219,6 +6224,31 @@ pub struct DeviceResult {
pub result: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub binding: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub signature: Option,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct ClientCommandReceipt {
+ pub command_id: String,
+ pub terminal_state: DeviceCommandOutcome,
+ pub issuer: String,
+ pub device: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub session: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub grant_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub run_id: Option,
+ pub command_kind: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub screen: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub reason: Option,
+ pub verified_result_signature: Option,
+ pub signed_result: Option,
}
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
pub struct DeviceCommand {
@@ -6231,9 +6261,31 @@ pub struct DeviceCommand {
pub issued_by: String,
pub issued_at: String,
pub expires_at: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub grant_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub run_id: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub control_grant: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub receipt_claim_id: Option,
pub state: DeviceCommandState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_code: Option,
}
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
+pub struct ClientControlGrantLifecycle {
+ #[serde(flatten)]
+ pub header: ResourceHeader,
+ #[serde(flatten)]
+ pub lifecycle: fractal_schema::client_control::ClientControlGrantLifecycleEvent,
+}
+impl std::ops::Deref for ClientControlGrantLifecycle {
+ type Target = fractal_schema::client_control::ClientControlGrantLifecycleEvent;
+ fn deref(&self) -> &Self::Target {
+ &self.lifecycle
+ }
+}
diff --git a/crates/fractal-client/src/lib.rs b/crates/fractal-client/src/lib.rs
index acacfab4677..3c5841bea44 100644
--- a/crates/fractal-client/src/lib.rs
+++ b/crates/fractal-client/src/lib.rs
@@ -16,6 +16,7 @@ pub mod propagation;
pub mod quota;
pub mod scenarios;
pub use contract::*;
+pub use fractal_schema::client_control::*;
pub use generated::*;
mod identities;
pub use identities::*;
@@ -3689,6 +3690,14 @@ impl Client {
self.post("/v1/client/device-results", result).await
}
+ pub async fn client_test_revoke(&self, device: &str) -> Result {
+ self.post(
+ "/v1/client/test-clients/revoke",
+ &serde_json::json!({"device": device}),
+ )
+ .await
+ }
+
pub async fn pairing_begin(
&self,
request: &PairingBegin,
diff --git a/crates/fractal-client/tests/client_control_golden.rs b/crates/fractal-client/tests/client_control_golden.rs
new file mode 100644
index 00000000000..c0b5c76248a
--- /dev/null
+++ b/crates/fractal-client/tests/client_control_golden.rs
@@ -0,0 +1,30 @@
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use fractal_client::{ClientResultBinding, client_result_canonical_bytes, visible_state_canonical_json, visible_state_digest};
+use ring::signature::{ECDSA_P256_SHA256_FIXED, UnparsedPublicKey};
+
+#[test]
+fn all_clients_share_canonical_bytes_and_p256_signatures() {
+ let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap();
+ let key = URL_SAFE_NO_PAD.decode(fixture["public_key_sec1_base64url"].as_str().unwrap()).unwrap();
+ let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_FIXED, &key);
+ for vector in fixture["vectors"].as_array().unwrap() {
+ let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap();
+ assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap());
+ assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap());
+ let canonical = client_result_canonical_bytes(&binding).unwrap();
+ assert_eq!(canonical, vector["canonical_bytes"].as_str().unwrap().as_bytes());
+ let signature = URL_SAFE_NO_PAD.decode(vector["signature_p1363_base64url"].as_str().unwrap()).unwrap();
+ verifier.verify(&canonical, &signature).unwrap();
+ for negative in vector["negative_bindings"].as_array().unwrap() {
+ let changed: ClientResultBinding = serde_json::from_value(negative.clone()).unwrap();
+ assert!(verifier.verify(&client_result_canonical_bytes(&changed).unwrap(), &signature).is_err());
+ }
+ // message.send's ordinary fields-v1 domain cannot consume a result signature.
+ let message = fixture["message_domain_bytes"].as_str().unwrap().as_bytes();
+ assert!(verifier.verify(message, &signature).is_err());
+ // The exact same enrolled test key signed this message-domain vector.
+ let message_signature = URL_SAFE_NO_PAD.decode(fixture["message_domain_signature_p1363_base64url"].as_str().unwrap()).unwrap();
+ verifier.verify(message, &message_signature).unwrap();
+ assert!(verifier.verify(&canonical, &message_signature).is_err());
+ }
+}
diff --git a/crates/fractal-client/tests/client_control_lifecycle.rs b/crates/fractal-client/tests/client_control_lifecycle.rs
new file mode 100644
index 00000000000..f9a27b6bdc2
--- /dev/null
+++ b/crates/fractal-client/tests/client_control_lifecycle.rs
@@ -0,0 +1,42 @@
+use fractal_client::{CollectionEvent, Resource};
+
+#[test]
+fn commands_collection_decodes_typed_grant_lifecycle_vectors() {
+ let vectors: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-lifecycle-v1.json")).unwrap();
+ for row in vectors["resources"].as_array().unwrap() {
+ let resource: Resource = serde_json::from_value(row.clone()).unwrap();
+ let Resource::ClientControlGrantLifecycle(lifecycle) = resource else { panic!("lifecycle must never decode as Unknown") };
+ assert_eq!(lifecycle.grant_id, row["grant_id"].as_str().unwrap());
+ assert_eq!(lifecycle.item_id.as_deref(), row["item_id"].as_str());
+ assert_eq!(lifecycle.sequence, row["sequence"].as_u64().unwrap());
+ assert_eq!(serde_json::to_value(lifecycle.lifecycle.state).unwrap(), row["state"]);
+ }
+ for frame in vectors["frames"].as_array().unwrap() {
+ let event: CollectionEvent = serde_json::from_value(frame.clone()).unwrap();
+ let (collection, rows) = match event {
+ CollectionEvent::Snapshot { collection, items, .. } => (collection, items),
+ CollectionEvent::Changes { collection, upserts, .. } => (collection, upserts),
+ _ => panic!("lifecycle uses existing snapshot/changes frames only"),
+ };
+ assert_eq!(collection, "commands");
+ assert!(matches!(&rows[0], Resource::ClientControlGrantLifecycle(_)));
+ }
+ for trace in vectors["traces"].as_array().unwrap() {
+ for step in trace["steps"].as_array().unwrap() {
+ if step["expected"] == "refuse-invalid-frame" {
+ assert!(serde_json::from_value::(step["resource"].clone()).is_err());
+ let mut frame = vectors["frames"][0].clone();
+ frame["items"] = serde_json::json!([step["resource"]]);
+ assert!(serde_json::from_value::(frame).is_err());
+ }
+ }
+ }
+ for sequence in [0, 1_u64 << 53] {
+ let mut invalid = vectors["resources"][0].clone();
+ invalid["sequence"] = sequence.into();
+ assert!(serde_json::from_value::(invalid).is_err());
+ }
+ let mut invalid = vectors["resources"][0].clone();
+ invalid["state"] = "future-state".into();
+ assert!(serde_json::from_value::(invalid).is_err());
+}
diff --git a/crates/fractal-schema/src/client_control.rs b/crates/fractal-schema/src/client_control.rs
new file mode 100644
index 00000000000..c4fde6a50bb
--- /dev/null
+++ b/crates/fractal-schema/src/client_control.rs
@@ -0,0 +1,148 @@
+// Generated by cargo run -p fractal-client-codegen; DO NOT EDIT.
+// Source: crates/fractal-client-codegen/templates/client_control.rs.in.
+//! Purpose-specific client results. This is not a message signing authorization.
+use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
+
+pub const CLIENT_RESULT_PURPOSE: &str = "client.device-result.v1";
+pub const CLIENT_RESULT_SIGNED_FIELDS: &[&str] = &[
+ "purpose", "command_kind", "device", "session", "grant_id", "run_id",
+ "command_id", "outcome", "error_code", "visible_state_sha256",
+];
+#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum ClientControlGrantLifecycleState { Started, Ended, Revoked }
+/// Session-scoped payload carried only by the existing commands collection.
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+pub struct ClientControlGrantLifecycleEvent {
+ pub grant_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option,
+ pub run_id: String,
+ pub device: String,
+ pub session: String,
+ pub state: ClientControlGrantLifecycleState,
+ pub reason: String,
+ #[serde(deserialize_with = "deserialize_lifecycle_sequence")]
+ pub sequence: u64,
+}
+fn deserialize_lifecycle_sequence<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result {
+ let sequence = u64::deserialize(deserializer)?;
+ if !(1..=(1_u64 << 53) - 1).contains(&sequence) {
+ return Err(serde::de::Error::custom("lifecycle sequence must be a positive JSON-safe integer"));
+ }
+ Ok(sequence)
+}
+#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "kebab-case")]
+pub enum ClientControlRefusal {
+ DeviceNotTestClient, GrantMissing, GrantExpired, ScreenNotGranted,
+ VerbNotGranted, BudgetExhausted, ActorNotGranted,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct ClientTestEnrollment {
+ pub device: String, pub kind: String, pub enrolled_by: String,
+ pub consent_ref: String, pub packet_digest: String, pub host: String,
+ pub instance: String, pub pairing_subject: String, pub public_key: String, pub session: String,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct ClientTestRevocation {
+ pub device: String, pub enrollment_id: String, pub revoked_by: String,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientVisibleItem { pub id: String, pub title: String, pub body: String }
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientVisibleState {
+ pub route: String,
+ pub screen: String,
+ pub visible_item_ids: Vec,
+ pub opened_item: Option,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientResultBinding {
+ pub purpose: String,
+ pub command_kind: String,
+ pub device: String,
+ pub session: String,
+ pub grant_id: String,
+ pub run_id: String,
+ pub command_id: String,
+ pub outcome: String,
+ pub error_code: Option,
+ pub visible_state: ClientVisibleState,
+}
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(deny_unknown_fields)]
+pub struct ClientControlGrant {
+ pub grant_id: String,
+ pub run_id: String,
+ pub device: String,
+ pub session: String,
+ pub actor_agent: String,
+ pub verbs: Vec,
+ pub screens: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")] pub item_id: Option,
+ pub max_commands: u64,
+ pub expires_at: String,
+ pub state: String,
+}
+/// RFC 8785 JCS for this closed, string-only schema. Keys are emitted in ASCII
+/// order explicitly, independent of serde_json's preserve_order feature.
+/// There are no numbers or arbitrary object keys in this type.
+pub fn visible_state_canonical_json(state: &ClientVisibleState) -> Result {
+ #[derive(Serialize)]
+ struct Item<'a> { body: &'a str, id: &'a str, title: &'a str }
+ #[derive(Serialize)]
+ struct State<'a> { opened_item: Option
- >, route: &'a str, screen: &'a str, visible_item_ids: &'a [String] }
+ serde_json::to_string(&State {
+ opened_item: state.opened_item.as_ref().map(|item| Item { body: &item.body, id: &item.id, title: &item.title }),
+ route: &state.route, screen: &state.screen, visible_item_ids: &state.visible_item_ids,
+ })
+}
+pub fn visible_state_digest(state: &ClientVisibleState) -> Result {
+ Ok(hex::encode(Sha256::digest(visible_state_canonical_json(state)?.as_bytes())))
+}
+/// Purpose is the first fields-v1 field. Values are JSON, not raw lines, so
+/// embedded newlines cannot inject fields. No trailing newline is signed.
+/// Callers MUST enforce the purpose constant, enrolled key, grant and windows.
+pub fn client_result_canonical_bytes(binding: &ClientResultBinding) -> Result, serde_json::Error> {
+ let digest = visible_state_digest(&binding.visible_state)?;
+ let mut lines = vec!["smallclaims-claim-fields-v1".to_owned()];
+ for (name, value) in [
+ ("purpose", Some(binding.purpose.as_str())),
+ ("command_kind", Some(binding.command_kind.as_str())),
+ ("device", Some(binding.device.as_str())),
+ ("session", Some(binding.session.as_str())),
+ ("grant_id", Some(binding.grant_id.as_str())),
+ ("run_id", Some(binding.run_id.as_str())),
+ ("command_id", Some(binding.command_id.as_str())),
+ ("outcome", Some(binding.outcome.as_str())),
+ ("error_code", binding.error_code.as_deref()),
+ ("visible_state_sha256", Some(digest.as_str())),
+ ] { lines.push(format!("{name}={}", serde_json::to_string(&value)?)); }
+ Ok(lines.join("\n").into_bytes())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn shared_golden_vectors() {
+ let fixture: serde_json::Value = serde_json::from_str(include_str!("../../../fixtures/clients/client-control-v1.golden.json")).unwrap();
+ for vector in fixture["vectors"].as_array().unwrap() {
+ let binding: ClientResultBinding = serde_json::from_value(vector["input"].clone()).unwrap();
+ assert_eq!(visible_state_canonical_json(&binding.visible_state).unwrap(), vector["canonical_visible_state"].as_str().unwrap());
+ assert_eq!(visible_state_digest(&binding.visible_state).unwrap(), vector["visible_state_sha256"].as_str().unwrap());
+ let bytes = client_result_canonical_bytes(&binding).unwrap();
+ assert_eq!(bytes, vector["canonical_bytes"].as_str().unwrap().as_bytes());
+ for mutation in vector["negative_bindings"].as_array().unwrap() {
+ let changed: ClientResultBinding = serde_json::from_value(mutation.clone()).unwrap();
+ assert_ne!(bytes, client_result_canonical_bytes(&changed).unwrap());
+ }
+ }
+ }
+}
diff --git a/crates/fractal-schema/src/lib.rs b/crates/fractal-schema/src/lib.rs
index 8b822a5387e..82eeaaf63d8 100644
--- a/crates/fractal-schema/src/lib.rs
+++ b/crates/fractal-schema/src/lib.rs
@@ -1,5 +1,6 @@
//! The authoritative st3 subject, resource, and claim registry.
+pub mod client_control;
pub mod arrangements;
pub mod custom;
pub mod accountable;
diff --git a/docs/st3/client-v0/device-commands.md b/docs/st3/client-v0/device-commands.md
index 8067214e7a7..6fef362096b 100644
--- a/docs/st3/client-v0/device-commands.md
+++ b/docs/st3/client-v0/device-commands.md
@@ -127,3 +127,139 @@ available for five minutes of ephemeral correlation before eviction. The process
discard a retained outcome early to make room. A daemon restart or eviction makes outcomes
unavailable. Expired commands are never executed on reconnection. The normal envelope's snapshot
describes the response context, not durable storage of the command.
+
+## Isolated client-control v1 signed results
+
+The generated client-control contract uses the same `phone.inspect` and
+`phone.navigate` verbs on iOS, web and TUI. `phone.message.read` is excluded from
+test grants. A granted request adds `grant_id`; its command carries `grant_id`,
+`run_id` and `control_grant` (including the pairing `session` actor). Ordinary
+ungranted reports retain the preceding wire shape.
+
+A granted `DeviceResult` adds `binding: ClientResultBinding` and the existing
+`signature: DeviceSignature` envelope. The binding contains `purpose`,
+`command_kind`, `device`, `session`, `grant_id`, `run_id`, `command_id`,
+`outcome`, nullable `error_code`, and `visible_state`. Outer command, outcome and
+error must agree with the binding; any outer `result` must equal `visible_state`.
+The closed visible-state type has `route`, `screen`, `visible_item_ids` and
+nullable `opened_item: {id, title, body}`. Arrays retain display order.
+
+`visible_state_sha256` is lowercase SHA-256 of UTF-8 RFC 8785 JCS JSON. The
+closed type admits only strings, null, arrays and fixed ASCII object keys:
+there is no floating-point normalization or arbitrary-key sorting ambiguity.
+No Unicode normalization is applied, lone surrogates are invalid, and slash,
+non-ASCII and U+2028/U+2029 are not escaped.
+
+Sign UTF-8 newline-joined lines below, **without a trailing newline**. Every
+right-hand value is its compact JSON encoding, including quotes for strings.
+The purpose is the first signed field, and the field order is normative:
+
+```text
+smallclaims-claim-fields-v1
+purpose="client.device-result.v1"
+command_kind="phone.inspect"
+device="device/example"
+session="client-session/example"
+grant_id="client-control-grant/example"
+run_id="mission-run/example"
+command_id="device-command/example"
+outcome="completed"
+error_code=null
+visible_state_sha256=""
+```
+
+Use P-256 ECDSA with SHA-256; signature encoding is 64-byte IEEE P1363 `r||s`,
+base64url without padding, not ASN.1 DER. The envelope format is `fields-v1` and
+`signed_fields` is exactly the ordered list above. The enrolled pairing key is
+the authority, never a caller-selected envelope key. Envelope `nonce` and
+`signed_at_unix_ms` are not signed or freshness authority: daemon time must be
+within both command and grant windows, and a command accepts only one result.
+This signature is purpose-specific and never authorizes `message.send` or a
+read-state mutation; fixture pairings must not acquire `control.messages`.
+
+The source templates live in `crates/fractal-client-codegen/templates`; regenerate
+all outputs with `cargo run -p fractal-client-codegen`. Rust exports canonical
+helpers through `fractal_schema::client_control` and `fractal_client`; TypeScript
+uses `ClientControl.generated.ts`, and Swift uses `ClientControl.generated.swift`.
+All three test suites read the same
+`fixtures/clients/client-control-v1.golden.json`, including fixed-public-test-key
+signatures and negative bindings for grant/run/command/purpose/kind/device/session,
+outcome, error and visible-state tampering. The fixture scalar and nonce are public
+test data, never enrollment keys.
+
+## Fixture enrollment and revocation
+
+Enrollment is a privileged local Unix-socket operation on the local-only
+`client-control-fixture` instance owned by `person/client-control-fixture`.
+The socket peer must have the daemon's UID. A remote bearer credential,
+request header, ordinary pairing, or main-instance operator cannot enroll.
+Each isolated client first generates its own P-256 key and uses the existing
+pairing flow. Enrollment binds the exact existing pairing subject, device and
+public key; it never generates keys or grants principal signing authority.
+Pairing scopes must include `read.projections` and `control.commands`, with
+only `read.*` scopes otherwise; `control.messages` is forbidden.
+
+The enrollment request records `device`, `pairing_subject`, `public_key`,
+`kind` (`ios`, `web`, `tui`), `host`, `instance`, `consent_ref` and
+`packet_digest`. The instance is exactly `client-control-fixture`.
+`consent_ref` must name the native ask `step-run/ask-β¦/ask`; `packet_digest`
+is the lowercase 64-hex SHA256 of the reviewed enrollment packet and input
+manifest. These become `consentRef` and `packetDigest` in the persisted
+`custom.client.test-enrolled` claim alongside the bound host, instance and kind.
+Invalid ask IDs return `consent-ref-invalid`; malformed digests return
+`consent-packet-invalid`; reuse across devices, kinds or re-enrollment returns
+`consent-ref-reused`. Re-enrollment or changed scope requires a fresh ask.
+
+Approval verification is an external mission gate: before the local call,
+verify the native ask was answered by `person/schickling` with explicit approval
+of the matching packet digest, kind, host and instance. Silence is not approval.
+The privileged operator attests that verified gate; the fixture daemon never
+connects to the main daemon or imports its person/ask claims. A signed exported
+consent receipt is not part of this protocol.
+
+The exact paired client's revoke control, a local agent, or the daemon-host
+operator appends `custom.client.test-revoked` naming the enrollment claim ID.
+This persisted withdrawal ends every grant bound to that enrollment, regardless
+of timestamps; new enrollment cannot resurrect old grants. Grant issue, command
+admission and result acceptance reload enrollment and pairing authority.
+Operators can revoke even after the pairing expires.
+
+## Grant lifecycle on the commands collection
+
+The existing session-scoped `commands` subscription carries generated
+`client-control-grant-lifecycle` resources alongside device commands, using the
+same snapshot/changes frames and typed `Resource` decoder. Each lifecycle row
+has the ordinary resource header plus `grant_id`, optional `item_id`, `run_id`,
+`device`, `session`, `state` (`started`, `ended`, `revoked`), `reason` and a
+monotonic integer `sequence` in `1...9007199254740991`. All client decoders
+reject zero or out-of-range sequences before lifecycle ordering checks; the
+shared zero-sequence trace expects `refuse-invalid-frame`, not a stale start.
+
+These rows drive the visible indicator and termination only. They do not carry
+verbs, screens or budget policy; policy remains in each
+`DeviceCommand.control_grant`. A command without an active, nonterminal
+lifecycle grant for its `grant_id` must produce no navigation or signature.
+When a grant binds `item_id`, every lifecycle row must carry that exact item.
+Clients ignore started rows whose sequence is not greater than the last
+observed sequence for the grant, and treat ended/revoked as permanently final
+for that grant ID: a later started row cannot reactivate it.
+
+`fixtures/clients/client-control-lifecycle-v1.json` is shared decoder and
+acceptance-trace data for all clients, covering each state, stale/reordered
+started rows, missing/wrong item binding, command-before-start and
+command-after-terminal. Context-dependent lifecycle checks belong to the
+client handler; decoding a resource never grants authority.
+Scenario folding ignores lifecycle-only control metadata rather than changing
+the visible resource state.
+
+TypeScript canonical hashing supports Hermes without WebCrypto through an
+injected `ClientControlSha256 = (bytes: Uint8Array) => Promise`.
+Pass it as the second argument to `visibleStateDigest` or
+`clientResultCanonicalBytes`; omitting it uses generated
+`defaultWebCryptoSha256`. Canonical encoding remains generated and unchanged;
+the injected implementation returns the raw 32 SHA-256 bytes, not hex or text.
+The default WebCrypto adapter copies input into an owned `ArrayBuffer`; injected
+hashers retain the general `Uint8Array` input contract.
+Invalid Unicode is refused with the exported TypeScript
+`ClientResultCanonicalizationError` and its `code: "invalid-unicode"`, including
+through `clientResultCanonicalBytes`; consumers must not match error messages.
diff --git a/docs/st3/client-v0/schemas/client-v0.schema.json b/docs/st3/client-v0/schemas/client-v0.schema.json
index 3daf13f9002..510f985eefd 100644
--- a/docs/st3/client-v0/schemas/client-v0.schema.json
+++ b/docs/st3/client-v0/schemas/client-v0.schema.json
@@ -457,18 +457,77 @@
"DeviceCommandRequest": {
"description": "One expiring foreground command. Default TTL is 30000 ms, maximum 60000 ms; at most eight pending commands per device.",
"oneOf": [
- {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.inspect"}, "args": {"$ref": "#/$defs/PhoneInspectArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}},
- {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.navigate"}, "args": {"$ref": "#/$defs/PhoneNavigateArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}},
- {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "kind": {"const": "phone.message.read"}, "args": {"$ref": "#/$defs/PhoneMessageReadArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}
+ {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.inspect"}, "args": {"$ref": "#/$defs/PhoneInspectArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}},
+ {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.navigate"}, "args": {"$ref": "#/$defs/PhoneNavigateArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}},
+ {"type": "object", "additionalProperties": false, "required": ["api_version", "device", "kind", "args"], "properties": {"api_version": {"enum": ["st3.client.v0", "fractal.client.v0"]}, "device": {"type": "string", "pattern": "^device/.+"}, "grant_id": {"type": "string"}, "kind": {"const": "phone.message.read"}, "args": {"$ref": "#/$defs/PhoneMessageReadArgs"}, "ttl_ms": {"type": "integer", "minimum": 1, "maximum": 60000, "default": 30000}}}
]
},
"DeviceCommandOutcome": {"enum": ["completed", "not-foreground", "expired", "refused", "failed"]},
"DeviceCommandState": {"enum": ["pending", "delivered", "completed", "not-foreground", "expired", "refused", "failed"]},
+ "ClientVisibleItem": {
+ "type": "object", "additionalProperties": false, "required": ["id", "title", "body"],
+ "properties": {"id": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}}
+ },
+ "ClientVisibleState": {
+ "type": "object", "additionalProperties": false, "required": ["route", "screen", "visible_item_ids", "opened_item"],
+ "properties": {"route": {"type": "string"}, "screen": {"type": "string"}, "visible_item_ids": {"type": "array", "items": {"type": "string"}}, "opened_item": {"anyOf": [{"$ref": "#/$defs/ClientVisibleItem"}, {"type": "null"}]}}
+ },
+ "ClientResultBinding": {
+ "type": "object", "additionalProperties": false,
+ "required": ["purpose", "command_kind", "device", "session", "grant_id", "run_id", "command_id", "outcome", "error_code", "visible_state"],
+ "properties": {
+ "purpose": {"const": "client.device-result.v1"}, "command_kind": {"enum": ["phone.inspect", "phone.navigate"]},
+ "device": {"type": "string"}, "session": {"type": "string"}, "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "command_id": {"type": "string"},
+ "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "error_code": {"type": ["string", "null"]}, "visible_state": {"$ref": "#/$defs/ClientVisibleState"}
+ }
+ },
+ "ClientControlGrant": {
+ "type": "object", "additionalProperties": false,
+ "required": ["grant_id", "run_id", "device", "session", "actor_agent", "verbs", "screens", "max_commands", "expires_at", "state"],
+ "properties": {
+ "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"}, "actor_agent": {"type": "string"},
+ "verbs": {"type": "array", "items": {"type": "string"}}, "screens": {"type": "array", "items": {"type": "string"}},
+ "item_id": {"type": "string"},
+ "max_commands": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}, "expires_at": {"$ref": "#/$defs/Timestamp"}, "state": {"type": "string"}
+ }
+ },
+ "ClientControlRefusal": {"enum": ["device-not-test-client", "grant-missing", "grant-expired", "screen-not-granted", "verb-not-granted", "budget-exhausted", "actor-not-granted"]},
+ "ClientTestEnrollment": {
+ "type": "object", "additionalProperties": false,
+ "required": ["device", "kind", "enrolledBy", "consentRef", "packetDigest", "host", "instance", "pairingSubject", "publicKey", "session"],
+ "properties": {
+ "device": {"type": "string"}, "kind": {"enum": ["ios", "web", "tui"]}, "enrolledBy": {"type": "string"}, "consentRef": {"type": "string"},
+ "packetDigest": {"type": "string"}, "host": {"type": "string"}, "instance": {"type": "string"}, "pairingSubject": {"type": "string"}, "publicKey": {"type": "string"}, "session": {"type": "string"}
+ }
+ },
+ "ClientTestRevocation": {
+ "type": "object", "additionalProperties": false, "required": ["device", "enrollmentId", "revokedBy"],
+ "properties": {"device": {"type": "string"}, "enrollmentId": {"type": "string"}, "revokedBy": {"type": "string"}}
+ },
+ "ClientCommandReceipt": {
+ "type": "object", "additionalProperties": false, "required": ["commandId", "terminalState", "issuer", "device", "commandKind", "verifiedResultSignature", "signedResult"],
+ "properties": {
+ "commandId": {"type": "string"}, "terminalState": {"$ref": "#/$defs/DeviceCommandOutcome"}, "issuer": {"type": "string"}, "device": {"type": "string"},
+ "session": {"type": "string"}, "grantId": {"type": "string"}, "runId": {"type": "string"}, "commandKind": {"type": "string"},
+ "screen": {"type": "string"}, "reason": {"$ref": "#/$defs/ClientControlRefusal"},
+ "verifiedResultSignature": {"anyOf": [{"$ref": "#/$defs/DeviceSignature"}, {"type": "null"}]}, "signedResult": {"anyOf": [{"$ref": "#/$defs/ClientResultBinding"}, {"type": "null"}]}
+ }
+ },
"DeviceResult": {
"type": "object", "additionalProperties": false, "required": ["command_id", "outcome"],
- "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}},
+ "properties": {"command_id": {"type": "string", "pattern": "^device-command/.+"}, "outcome": {"$ref": "#/$defs/DeviceCommandOutcome"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}, "binding": {"$ref": "#/$defs/ClientResultBinding"}, "signature": {"$ref": "#/$defs/DeviceSignature"}},
"description": "Terminal phone report; authenticated pairing supplies the device identity. The daemon clock adjudicates expiry."
},
+ "ClientControlGrantLifecycle": {
+ "allOf": [{"$ref": "#/$defs/ResourceHeader"}], "type": "object",
+ "required": ["id", "kind", "revision", "updated_at", "grant_id", "run_id", "device", "session", "state", "reason", "sequence"],
+ "properties": {
+ "kind": {"const": "client-control-grant-lifecycle"}, "grant_id": {"type": "string"}, "item_id": {"type": "string"},
+ "run_id": {"type": "string"}, "device": {"type": "string"}, "session": {"type": "string"},
+ "state": {"enum": ["started", "ended", "revoked"]}, "reason": {"type": "string"},
+ "sequence": {"type": "integer", "minimum": 1, "maximum": 9007199254740991}
+ }
+ },
"DeviceCommand": {
"allOf": [{"$ref": "#/$defs/ResourceHeader"}],
"type": "object",
@@ -481,6 +540,8 @@
"device_id": {"type": "string", "pattern": "^device/.+"},
"session_actor": {"$ref": "#/$defs/Id"}, "issued_by": {"$ref": "#/$defs/Id"},
"issued_at": {"$ref": "#/$defs/Timestamp"}, "expires_at": {"$ref": "#/$defs/Timestamp"},
+ "grant_id": {"type": "string"}, "run_id": {"type": "string"}, "control_grant": {"$ref": "#/$defs/ClientControlGrant"},
+ "receipt_claim_id": {"type": "string"},
"state": {"$ref": "#/$defs/DeviceCommandState"}, "result": {}, "error_code": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9_.-]+$"}
},
"oneOf": [
@@ -691,6 +752,7 @@
"machine",
"device",
"device-command",
+ "client-control-grant-lifecycle",
"operation",
"history",
"session",
@@ -3121,6 +3183,7 @@
{
"$ref": "#/$defs/DeviceCommand"
},
+ {"$ref": "#/$defs/ClientControlGrantLifecycle"},
{
"$ref": "#/$defs/Operation"
},
diff --git a/fixtures/clients/client-control-lifecycle-v1.json b/fixtures/clients/client-control-lifecycle-v1.json
new file mode 100644
index 00000000000..ef6d355fcad
--- /dev/null
+++ b/fixtures/clients/client-control-lifecycle-v1.json
@@ -0,0 +1,313 @@
+{
+ "version": 1,
+ "rules": [
+ "A bound grant item_id must be present and equal on every lifecycle row.",
+ "Started sequence must be strictly greater than the last observed sequence for that grant_id.",
+ "Ended and revoked are final; a subsequent started row never reactivates that grant_id.",
+ "Lifecycle only controls indicator/termination; each command still requires its own DeviceCommand.control_grant policy.",
+ "No active nonterminal lifecycle for command.grant_id means refuse without navigation or signing."
+ ],
+ "resources": [
+ {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "1",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 1
+ },
+ {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "2",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "ended",
+ "reason": "run-ended",
+ "sequence": 2
+ },
+ {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "3",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "revoked",
+ "reason": "revoked",
+ "sequence": 3
+ }
+ ],
+ "frames": [
+ {
+ "kind": "snapshot",
+ "id": "commands",
+ "collection": "commands",
+ "snapshot": {
+ "id": "snapshot/golden",
+ "host_id": "host/fixture",
+ "store_index": 1,
+ "projection_version": "client-projection.v0",
+ "created_at": "2026-11-10T12:00:00Z"
+ },
+ "order": [
+ "client-control-grant-lifecycle/golden"
+ ],
+ "has_more": false,
+ "items": [
+ {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "1",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 1
+ }
+ ]
+ },
+ {
+ "kind": "changes",
+ "id": "commands",
+ "collection": "commands",
+ "snapshot": {
+ "id": "snapshot/golden",
+ "host_id": "host/fixture",
+ "store_index": 2,
+ "projection_version": "client-projection.v0",
+ "created_at": "2026-11-10T12:00:00Z"
+ },
+ "order": [
+ "client-control-grant-lifecycle/golden"
+ ],
+ "has_more": false,
+ "upserts": [
+ {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "2",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "ended",
+ "reason": "run-ended",
+ "sequence": 2
+ }
+ ],
+ "removes": []
+ },
+ {
+ "kind": "changes",
+ "id": "commands",
+ "collection": "commands",
+ "snapshot": {
+ "id": "snapshot/golden",
+ "host_id": "host/fixture",
+ "store_index": 3,
+ "projection_version": "client-projection.v0",
+ "created_at": "2026-11-10T12:00:00Z"
+ },
+ "order": [
+ "client-control-grant-lifecycle/golden"
+ ],
+ "has_more": false,
+ "upserts": [
+ {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "3",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "revoked",
+ "reason": "revoked",
+ "sequence": 3
+ }
+ ],
+ "removes": []
+ }
+ ],
+ "traces": [
+ {
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "steps": [
+ {
+ "command_grant_id": "client-control-grant/golden",
+ "expected": "refuse-no-active-lifecycle"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "1",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 1
+ },
+ "expected": "activate"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "1",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 1
+ },
+ "expected": "ignore-stale-started"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "0",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 0
+ },
+ "expected": "refuse-invalid-frame"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "2",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/other/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 2
+ },
+ "expected": "ignore-item-mismatch"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "2",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 2
+ },
+ "expected": "ignore-item-mismatch"
+ },
+ {
+ "command_grant_id": "client-control-grant/golden",
+ "expected": "allow-lifecycle-gate-only"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "2",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "ended",
+ "reason": "run-ended",
+ "sequence": 2
+ },
+ "expected": "terminate"
+ },
+ {
+ "command_grant_id": "client-control-grant/golden",
+ "expected": "refuse-terminal-lifecycle"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "4",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "started",
+ "reason": "granted",
+ "sequence": 4
+ },
+ "expected": "ignore-final-grant"
+ },
+ {
+ "resource": {
+ "id": "client-control-grant-lifecycle/golden",
+ "kind": "client-control-grant-lifecycle",
+ "revision": "3",
+ "updated_at": "2026-11-10T12:00:00Z",
+ "grant_id": "client-control-grant/golden",
+ "item_id": "step-run/ask-golden/ask",
+ "run_id": "mission-run/golden",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "state": "revoked",
+ "reason": "revoked",
+ "sequence": 3
+ },
+ "expected": "remain-terminal"
+ },
+ {
+ "command_grant_id": "client-control-grant/golden",
+ "expected": "refuse-terminal-lifecycle"
+ }
+ ]
+ }
+ ]
+}
diff --git a/fixtures/clients/client-control-v1.golden.json b/fixtures/clients/client-control-v1.golden.json
new file mode 100644
index 00000000000..97bd57cb9ff
--- /dev/null
+++ b/fixtures/clients/client-control-v1.golden.json
@@ -0,0 +1,501 @@
+{
+ "_generated": "cargo run -p fractal-client-codegen; source templates/client-control-v1.golden.json.in; fixed PUBLIC TEST key scalar 1, nonce scalar 2, never use for enrollment",
+ "format": "client-control-v1",
+ "public_key_sec1_base64url": "BGsX0fLhLEJH-Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT-NC4v4af5uO5-tKfA-eFivOM1drMV7Oy7ZAaDe_UfU",
+ "private_scalar_hex_for_tests": "0000000000000000000000000000000000000000000000000000000000000001",
+ "message_domain_bytes": "smallclaims-claim-fields-v1\nmessage/test\nmessage.sent\nperson/client-control-fixture\ncontent=\"hello\"",
+ "message_domain_signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXgRe11q295ks9iyv8QCIwcWwVU7WhRTUdQe_JG0MhGy1w",
+ "vectors": [
+ {
+ "name": "inbox",
+ "input": {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ "canonical_visible_state": "{\"opened_item\":null,\"route\":\"/inbox\",\"screen\":\"NowRoot\",\"visible_item_ids\":[\"step-run/ask-golden/ask\"]}",
+ "visible_state_sha256": "4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453",
+ "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.inspect\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-0\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"4cfea1d08a82607334d7c43e6781b5ae1c2c5066145a5965391e99994a9fd453\"",
+ "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXiuu76h6230PGyTZfFujp1lxwC70zSTNzoxlic27FPzmg",
+ "negative_bindings": [
+ {
+ "purpose": "message.send",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/other",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/other",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/other",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.message.read",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/other",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/other",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "failed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": "tampered",
+ "visible_state": {
+ "route": "/inbox",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.inspect",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-0",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/tampered",
+ "screen": "NowRoot",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask"
+ ],
+ "opened_item": null
+ }
+ }
+ ]
+ },
+ {
+ "name": "unicode-opened-item",
+ "input": {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ "canonical_visible_state": "{\"opened_item\":{\"body\":\"Line 1\\n\\t\\\"\\\\\\u0000\\u001f<>/β¨β©\",\"id\":\"step-run/ask-golden/ask\",\"title\":\"Seed Γ© π\"},\"route\":\"/attention/step-run/ask-golden/ask\",\"screen\":\"Attention\",\"visible_item_ids\":[\"step-run/ask-golden/ask\",\"message/Γ©π\"]}",
+ "visible_state_sha256": "2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3",
+ "canonical_bytes": "smallclaims-claim-fields-v1\npurpose=\"client.device-result.v1\"\ncommand_kind=\"phone.navigate\"\ndevice=\"device/test-tui\"\nsession=\"client-session/test-tui\"\ngrant_id=\"client-control-grant/golden\"\nrun_id=\"mission-run/golden\"\ncommand_id=\"device-command/golden-1\"\noutcome=\"completed\"\nerror_code=null\nvisible_state_sha256=\"2203b99e4166cda8d04df74ed0c00043490699105058540edb39b86dbf094db3\"",
+ "signature_p1363_base64url": "fPJ7GI0DT36KUjgDBLUaw8CJaeJ38hs1pgtI_EdmmXi45Ckt8qvn0tLGFBvQ7AEZ-86R5CQtnzYJQ5kea6paXQ",
+ "negative_bindings": [
+ {
+ "purpose": "message.send",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/other",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/other",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/other",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.message.read",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/other",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/other",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "failed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": "tampered",
+ "visible_state": {
+ "route": "/attention/step-run/ask-golden/ask",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ },
+ {
+ "purpose": "client.device-result.v1",
+ "command_kind": "phone.navigate",
+ "device": "device/test-tui",
+ "session": "client-session/test-tui",
+ "grant_id": "client-control-grant/golden",
+ "run_id": "mission-run/golden",
+ "command_id": "device-command/golden-1",
+ "outcome": "completed",
+ "error_code": null,
+ "visible_state": {
+ "route": "/tampered",
+ "screen": "Attention",
+ "visible_item_ids": [
+ "step-run/ask-golden/ask",
+ "message/Γ©π"
+ ],
+ "opened_item": {
+ "id": "step-run/ask-golden/ask",
+ "title": "Seed Γ© π",
+ "body": "Line 1\n\t\"\\\u0000\u001f<>/β¨β©"
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
diff --git a/release-notes/client-control-canonical-v1.json b/release-notes/client-control-canonical-v1.json
new file mode 100644
index 00000000000..70114afc2b1
--- /dev/null
+++ b/release-notes/client-control-canonical-v1.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "summary": "Generate one purpose-specific canonical client-control result format for Rust, TypeScript and Swift",
+ "replay": {"status":"none","detail":"Canonical helpers and wire models do not change store replay or migrate existing claims."},
+ "schema": {"status":"changed","detail":"Adds closed visible-state, signed-result binding and per-run control-grant wire models. Granted commands carry grant/run metadata; result requests optionally carry a binding and existing DeviceSignature envelope."},
+ "rules": {"status":"changed","detail":"The client.device-result.v1 domain binds command kind, enrolled device and pairing session, grant, run, command id, outcome, error and RFC 8785 visible-state digest. It is never message.send authority and requires no control.messages scope."},
+ "compatibility": {"status":"changed","detail":"The existing phone.inspect and phone.navigate verb names are shared by all test-client kinds. Legacy ungranted request/result shapes remain decodable. All clients share fixed-key golden vectors and cross-domain rejection tests."},
+ "downtime": {"status":"none","detail":"Source-only contract generation requires no live setup, enrollment, daemon or pairing changes."},
+ "manual": {"status":"none","detail":"Regenerate checked-in canonical outputs with cargo run -p fractal-client-codegen. Fixture key scalar and signing nonce are public test material, never production identities."},
+ "recovery": {"status":"none","detail":"No persisted data or runtime state is changed by canonical source generation."}
+}
diff --git a/release-notes/client-control-lifecycle-schema.json b/release-notes/client-control-lifecycle-schema.json
new file mode 100644
index 00000000000..b76c9767a4e
--- /dev/null
+++ b/release-notes/client-control-lifecycle-schema.json
@@ -0,0 +1,11 @@
+{
+ "version": 1,
+ "summary": "Generate typed session-scoped client-control grant lifecycle resources and injectable TypeScript SHA-256",
+ "replay": {"status":"none","detail":"Wire schema and client decoder sources only; this fragment does not change claim replay."},
+ "schema": {"status":"changed","detail":"Adds client-control-grant-lifecycle Resource with grant/run/device/session/item binding, started/ended/revoked state, reason and monotonic sequence on existing commands snapshot/changes frames."},
+ "rules": {"status":"changed","detail":"Lifecycle rows drive indicators and termination, not command policy. Decoders reject sequence zero and values above 2^53-1 before ordering checks. Bound item IDs must match; stale valid started rows are ignored; terminal grants cannot reactivate; commands without active lifecycle state fail closed."},
+ "compatibility": {"status":"changed","detail":"Rust, Swift and TypeScript decode generated lifecycle resources; older clients preserve unknown resources. Scenario folding ignores lifecycle metadata. TypeScript canonical helpers accept generic injected SHA-256 bytes for Hermes; the default WebCrypto adapter owns its input buffer and exposes ClientResultCanonicalizationError with invalid-unicode code. Canonical bytes remain unchanged."},
+ "downtime": {"status":"none","detail":"No live setup, enrollment or daemon operation is performed by these source changes."},
+ "manual": {"status":"none","detail":"Integrate source layers before one cargo run -p fractal-client-codegen. Shared lifecycle resources, frames and fail-closed traces are checked by client tests."},
+ "recovery": {"status":"none","detail":"No stored data migration is introduced by generated lifecycle decoder source."}
+}