@byok-sdk/cloud 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * GAP-2: why a `cloud.steerTask` call was refused, and the one input that
3
+ * decides it.
4
+ *
5
+ * The reference server (`ConnectionHub.steerTask`, `packages/server/src/hub.ts`)
6
+ * reaches this decision from `TaskSnapshot.claimedRuntimeCapabilities` — the
7
+ * capability block the CLAIMING adapter reported for itself on its own
8
+ * `task.claim`, frozen at the `Offered -> Claimed` transition. Cloud keeps the
9
+ * same single source: {@link TaskAttempt.claimedRuntimeCapabilities}, written by
10
+ * the claim that wins the ownership CAS (`stores/ports.ts`) and never again.
11
+ *
12
+ * The gap this closes is not cosmetic: only pi's adapter implements steering,
13
+ * and Claude's and Codex's THROW on receiving `task.steer`, which stalls that
14
+ * device's redelivery cursor at that seq forever. So the refusal has to happen
15
+ * before an envelope exists, from a fact that shares a lifecycle with the
16
+ * task-to-runtime binding.
17
+ *
18
+ * Fail-closed on unknown, deliberately: `steer_unsupported_runtime` covers both
19
+ * "the claiming adapter reported `steer: false`" and "this attempt carries no
20
+ * capability snapshot at all" (a daemon whose claim predates the field, an
21
+ * attempt claimed before migration `0018`). Refusing an unknown is a
22
+ * recoverable, operator-visible error; guessing "supported" reintroduces the
23
+ * permanent cursor stall this gate exists to prevent. There is deliberately NO
24
+ * runtime-id allow-list here either — a table mapping `pi -> steerable` would be
25
+ * a second capability authority in the coordination plane, drifting the moment a
26
+ * runtime gains or loses the feature, and the daemon already reports the truth.
27
+ *
28
+ * SINGLE SOURCE, deliberately: the gate reads the claim snapshot and NOTHING
29
+ * from the connection layer. Cloud's connection-level equivalent is
30
+ * `DeviceRecord.capabilities`, written from a bearer-authenticated `conn.hello`
31
+ * (`inbound.ts`) — discovery data describing a device BUILD, not the adapter
32
+ * that took this task. A device can reconnect later with a different adapter
33
+ * set; a task already running must keep being judged against what was true when
34
+ * it was claimed. Same pin the reference server holds in
35
+ * `packages/server/src/__tests__/steer-runtime-capability-gate.test.ts`.
36
+ */
37
+ import type { RuntimeId } from '@byok-sdk/protocol';
38
+ import type { TaskAttemptStatus } from './stores/ports';
39
+ /**
40
+ * Stable strings a caller switches on (an operator UI, an HTTP surface mapping
41
+ * this to a status code) rather than matching error text. Byte-identical to the
42
+ * reference server's `SteerRejectionCode` (`packages/server/src/hub.ts`), so a
43
+ * host that moves from the embedded server to the hosted kernel keeps its own
44
+ * mapping unchanged.
45
+ *
46
+ * - `task_terminal` — the attempt already reached `complete`/`failed`/
47
+ * `cancelled`. Checked FIRST, so a terminal attempt that is also (obviously)
48
+ * not running reports the more specific truth, and a steer racing a terminal
49
+ * transition always resolves terminal-first.
50
+ * - `task_not_running` — the attempt exists and is live, but is `offered`/
51
+ * `claimed`/`cancel_requested`; there is no running turn to steer yet.
52
+ * - `steer_unsupported_runtime` — the runtime that CLAIMED this attempt cannot
53
+ * be steered, per the claim-time capability snapshot. A MISSING snapshot
54
+ * rejects under this same code: unknown is not supported.
55
+ */
56
+ export type SteerRejectionCode = 'steer_unsupported_runtime' | 'task_not_running' | 'task_terminal';
57
+ /**
58
+ * Thrown by `ByokCloud.steerTask` instead of a generic `Error`, so a caller can
59
+ * tell WHY a steer was refused without matching on message text — the same
60
+ * typed-error idiom as `StaleApprovalError` (`approval-control.ts`).
61
+ *
62
+ * A distinct class rather than a `CloudErrorCode`, for the same reason as
63
+ * `StaleApprovalError`: it carries the state and the runtime the caller needs to
64
+ * explain the refusal, and a code alone cannot.
65
+ *
66
+ * Thrown before any mailbox row is allocated — a refused steer is a NON-EVENT,
67
+ * exactly like a refused approval.
68
+ */
69
+ export declare class SteerRejectedError extends Error {
70
+ readonly taskId: string;
71
+ readonly code: SteerRejectionCode;
72
+ /** The attempt's status at the moment the steer was refused. */
73
+ readonly status: TaskAttemptStatus;
74
+ /** {@link TaskAttempt.claimedRuntime} — `undefined` when nothing was ever recorded, which is itself a reason `steer_unsupported_runtime` can fire. */
75
+ readonly runtime: RuntimeId | undefined;
76
+ constructor(taskId: string, code: SteerRejectionCode,
77
+ /** The attempt's status at the moment the steer was refused. */
78
+ status: TaskAttemptStatus,
79
+ /** {@link TaskAttempt.claimedRuntime} — `undefined` when nothing was ever recorded, which is itself a reason `steer_unsupported_runtime` can fire. */
80
+ runtime: RuntimeId | undefined);
81
+ }
@@ -8,5 +8,5 @@ export declare class InMemoryAgentEgressStore implements AgentEgressStore {
8
8
  readonly record: AgentEgressRecord;
9
9
  readonly created: boolean;
10
10
  }>;
11
- get(tenant: TenantId, deviceId: string, eventId: string): Promise<AgentEgressRecord | undefined>;
11
+ get(tenant: TenantId, deviceId: string, agentRef: AgentEgressRecord['payload']['agentRef'], eventId: string): Promise<AgentEgressRecord | undefined>;
12
12
  }
@@ -1,9 +1,10 @@
1
1
  import type { Clock, TenantId } from '@byok-sdk/core';
2
- import { type ApprovalTimelineAppendInput, type ApprovalTimelineStore, type ApprovalTimelineTail } from '../../approval-timeline';
2
+ import { type ApprovalTimelineAppendInput, type ApprovalTimelineResolvePendingInput, type ApprovalTimelineResolvePendingResult, type ApprovalTimelineStore, type ApprovalTimelineTail } from '../../approval-timeline';
3
3
  export declare class InMemoryApprovalTimelineStore implements ApprovalTimelineStore {
4
4
  #private;
5
5
  private readonly clock;
6
6
  constructor(clock: Clock);
7
7
  append(tenant: TenantId, input: ApprovalTimelineAppendInput): Promise<ApprovalTimelineTail>;
8
+ resolvePending(tenant: TenantId, input: ApprovalTimelineResolvePendingInput): Promise<ApprovalTimelineResolvePendingResult>;
8
9
  read(tenant: TenantId, taskId: string): Promise<ApprovalTimelineTail | undefined>;
9
10
  }
@@ -77,6 +77,7 @@ export declare class InMemoryBlobContentProxy implements BlobContentProxy {
77
77
  #private;
78
78
  constructor(registry: InMemoryBlobRegistry);
79
79
  verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): Promise<boolean>;
80
+ expectedUploadBytes(blobId: string): Promise<bigint | undefined>;
80
81
  writeContent(blobId: string, data: Uint8Array): Promise<BlobWriteResult>;
81
82
  /**
82
83
  * Never returns `{ok:false}`: this composition holds the bytes in the same
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * In-memory {@link InboundDedupStore} (N3).
3
3
  *
4
- * A bounded ring per (tenant, device), not an unbounded set: the wire is
5
- * at-least-once (§9), so this makes processing at-most-once without letting a
6
- * chatty device grow memory without limit. Check-and-record is one call, so a
7
- * composition cannot accidentally split it into a racy read-then-write.
4
+ * A bounded ring per physical (tenant, device) or strict (tenant, device,
5
+ * AgentRef) fact, not an unbounded set: the wire is at-least-once (§9), so
6
+ * this makes processing at-most-once without letting a chatty device or Agent
7
+ * grow memory without limit. Check-and-record is one call, so a composition
8
+ * cannot accidentally split it into a racy read-then-write.
8
9
  */
9
10
  import { type TenantId } from '@byok-sdk/core';
11
+ import type { AgentRef } from '@byok-sdk/protocol';
10
12
  import type { InboundDedupStore } from '../ports';
11
- /** Ids retained per device. Same order of magnitude as the reference server's ring. */
13
+ /** Ids retained per physical or Agent-bound key. Same depth as the reference server's ring. */
12
14
  export declare const DEDUP_RING_CAPACITY = 1024;
13
15
  export declare class InMemoryInboundDedupStore implements InboundDedupStore {
14
16
  #private;
15
17
  constructor(capacity?: number);
16
18
  checkAndRecord(tenant: TenantId, deviceId: string, envelopeId: string): Promise<boolean>;
19
+ checkAndRecordAgent(tenant: TenantId, deviceId: string, agentRef: AgentRef, envelopeId: string): Promise<boolean>;
17
20
  }
@@ -17,6 +17,5 @@ export declare class InMemoryNonceStore implements NonceStore {
17
17
  /** Number of records currently held (post-sweep). Test-facing only. */
18
18
  get size(): number;
19
19
  issue(tenant: TenantId, deviceId: string): Promise<string>;
20
- validate(tenant: TenantId, deviceId: string, nonce: string): Promise<boolean>;
21
- markUsed(tenant: TenantId, nonce: string): Promise<void>;
20
+ consumeIfValid(tenant: TenantId, deviceId: string, nonce: string): Promise<boolean>;
22
21
  }
@@ -2,17 +2,21 @@
2
2
  * In-memory {@link PairingCodeStore}: single-use codes bound to the tenant and
3
3
  * product they were minted for.
4
4
  *
5
- * `redeem` answers `undefined` for unknown, expired, and already-used alike.
5
+ * Enrollment answers `undefined` for unknown, expired, and already-used alike.
6
6
  * The reference server distinguishes those three in its 401 text; a hosted,
7
7
  * multi-tenant surface deliberately does not — the code is a bearer credential
8
8
  * addressable across every tenant, and "already used" versus "never existed"
9
9
  * is exactly the difference an attacker enumerating codes would pay for.
10
+ *
11
+ * Code issuance and enrollment project this one authority into separate ports.
12
+ * The enrollment path serializes each code through registration and flips
13
+ * `used` only after its shared device directory has accepted the row.
10
14
  */
11
15
  import type { Clock, TenantId } from '@byok-sdk/core';
12
- import type { PairingCodeClaims, PairingCodeInfo, PairingCodeIssueInput, PairingCodeStore } from '../ports';
13
- export declare class InMemoryPairingCodeStore implements PairingCodeStore {
16
+ import type { DeviceDirectory, DeviceRecord, PairingEnrollment, PairingEnrollmentInput, PairingCodeInfo, PairingCodeIssueInput, PairingCodeStore } from '../ports';
17
+ export declare class InMemoryPairingCodeStore implements PairingCodeStore, PairingEnrollment {
14
18
  #private;
15
- constructor(clock: Clock);
19
+ constructor(clock: Clock, devices: DeviceDirectory);
16
20
  issue(tenant: TenantId, input: PairingCodeIssueInput): Promise<PairingCodeInfo>;
17
- redeem(code: string): Promise<PairingCodeClaims | undefined>;
21
+ redeemAndRegister(input: PairingEnrollmentInput): Promise<DeviceRecord | undefined>;
18
22
  }
@@ -16,7 +16,11 @@
16
16
  * unfalsifiable.
17
17
  */
18
18
  import { type Clock, type TenantId } from '@byok-sdk/core';
19
- import type { AgentRef, TaskAttempt, TaskAttemptStatus, TaskAttemptStore } from '../ports';
19
+ import type { RuntimeCapabilities, RuntimeId } from '@byok-sdk/protocol';
20
+ import { type AgentMessageAdmission, type AgentRef, type TaskAttempt, type TaskAttemptListQuery, type TaskAttemptPage, type TaskAttemptStatus, type TaskAttemptStore } from '../ports';
21
+ interface StoredAgentMessageAdmission extends AgentMessageAdmission {
22
+ readonly deviceId: string;
23
+ }
20
24
  export declare class InMemoryTaskAttemptStore implements TaskAttemptStore {
21
25
  #private;
22
26
  constructor(clock: Clock, state?: InMemoryTaskAttemptState);
@@ -33,11 +37,33 @@ export declare class InMemoryTaskAttemptStore implements TaskAttemptStore {
33
37
  readonly attempt: TaskAttempt;
34
38
  readonly created: boolean;
35
39
  }>;
40
+ reserveAgentMessage(tenant: TenantId, input: {
41
+ readonly taskId: string;
42
+ readonly deviceId: string;
43
+ readonly messageId: string;
44
+ readonly payloadBody: string;
45
+ }): Promise<'reserved' | 'pending' | 'rejected'>;
46
+ readAgentMessage(tenant: TenantId, input: {
47
+ readonly taskId: string;
48
+ readonly deviceId: string;
49
+ readonly messageId: string;
50
+ readonly payloadBody: string;
51
+ }): Promise<AgentMessageAdmission | undefined>;
52
+ finalizeAgentMessage(tenant: TenantId, input: {
53
+ readonly taskId: string;
54
+ readonly deviceId: string;
55
+ readonly messageId: string;
56
+ readonly payloadBody: string;
57
+ readonly terminalBody: string;
58
+ }): Promise<AgentMessageAdmission | undefined>;
36
59
  get(tenant: TenantId, taskId: string): Promise<TaskAttempt | undefined>;
37
60
  getMany(tenant: TenantId, taskIds: readonly string[]): Promise<readonly TaskAttempt[]>;
61
+ list(tenant: TenantId, query: TaskAttemptListQuery): Promise<TaskAttemptPage>;
38
62
  claim(tenant: TenantId, input: {
39
63
  taskId: string;
40
64
  deviceId: string;
65
+ runtime?: RuntimeId;
66
+ capabilities?: RuntimeCapabilities;
41
67
  }): Promise<TaskAttempt | undefined>;
42
68
  recordStatus(tenant: TenantId, input: {
43
69
  readonly taskId: string;
@@ -50,8 +76,10 @@ export declare class InMemoryTaskAttemptStore implements TaskAttemptStore {
50
76
  export declare class InMemoryTaskAttemptState {
51
77
  #private;
52
78
  readonly attempts: Map<string, TaskAttempt>;
79
+ readonly messageAdmissions: Map<string, StoredAgentMessageAdmission>;
53
80
  constructor(clock: Clock);
54
81
  now(): string;
55
82
  /** Serialize every state-changing operation for one tenant/task key. */
56
83
  mutate<T>(key: string, operation: () => T | Promise<T>): Promise<T>;
57
84
  }
85
+ export {};
@@ -13,20 +13,18 @@
13
13
  * 1. **Async.** Every method returns a `Promise`, so a SQL or KV composition
14
14
  * can implement the same contract the in-memory reference does.
15
15
  * 2. **Tenant-first.** Every method's first parameter is a required
16
- * `TenantId` — with exactly two documented exceptions below, each of
17
- * which is pre-tenant *by construction* because the credential it is
18
- * handed is itself what resolves the tenant.
16
+ * `TenantId` — with one documented lookup exception below. Pairing code
17
+ * consumption is a separate composition-owned enrollment operation: its
18
+ * bearer code resolves the tenant internally and it returns only the
19
+ * resulting device row.
19
20
  *
20
- * The two exceptions:
21
+ * The lookup exception:
21
22
  *
22
23
  * - {@link DeviceDirectory.resolveByDeviceId} — `POST /byok/challenge` and
23
24
  * `POST /byok/token` carry only a deviceId (the pinned wire contract), and
24
25
  * the row is what tells the deployment which tenant to mint for.
25
- * - {@link PairingCodeStore.redeem} — the code IS the tenant lookup; it was
26
- * minted against a tenant that only the host's control plane knew.
27
- *
28
- * Neither is reachable through {@link TenantStores} (see `tenant-stores.ts`),
29
- * so a device-facing handler cannot call them.
26
+ * It is not reachable through {@link TenantStores} (see `tenant-stores.ts`),
27
+ * so a device-facing handler cannot call it.
30
28
  *
31
29
  * The byte-proxy trio that used to be a third pre-tenant exception is no
32
30
  * longer part of this bundle at all: it moved to {@link BlobContentProxy},
@@ -35,7 +33,7 @@
35
33
  * bytes (see the blobs section below).
36
34
  */
37
35
  import type { MailboxBody, MailboxMessage, PresenceStore, StorageReservation, TenantId, TenantReadiness } from '@byok-sdk/core';
38
- import type { AgentEgressReliablePayload, AgentRef } from '@byok-sdk/protocol';
36
+ import type { AgentEgressReliablePayload, AgentRef, RuntimeCapabilities, RuntimeId } from '@byok-sdk/protocol';
39
37
  export type { AgentEgressReliablePayload, AgentRef } from '@byok-sdk/protocol';
40
38
  import type { ActivityStore } from '../activity';
41
39
  import type { ApprovalTimelineStore } from '../approval-timeline';
@@ -151,33 +149,61 @@ export interface PairingCodeIssueInput {
151
149
  }
152
150
  export interface PairingCodeStore {
153
151
  issue(tenant: TenantId, input: PairingCodeIssueInput): Promise<PairingCodeInfo>;
154
- /**
155
- * Validate and CONSUME a code, returning the claims it was minted with, or
156
- * `undefined` when it is unknown, expired, or already used — one answer for
157
- * all three, so a redeem attempt is never an existence oracle. Single-use is
158
- * what makes the caller's "redeem, then register the device row" sequence
159
- * exclusive: a second redeem can never reach the registration step.
160
- */
161
- redeem(code: string): Promise<PairingCodeClaims | undefined>;
152
+ }
153
+ /**
154
+ * The pre-tenant facts a pairing request carries into enrollment. Tenant and
155
+ * product are deliberately absent: the guarded pairing-code row is their sole
156
+ * authority, and no caller can choose the registration scope.
157
+ */
158
+ export interface PairingEnrollmentInput {
159
+ readonly pairingCode: string;
160
+ readonly deviceId: string;
161
+ readonly deviceName: string;
162
+ readonly devicePublicKey: string;
163
+ readonly proofKeyId: string;
164
+ readonly proofKeyEpoch: number;
165
+ readonly machineId?: string;
166
+ }
167
+ /**
168
+ * The only pairing-code consumption operation. Implementations atomically
169
+ * consume a valid code, apply machine supersession/state cleanup, and register
170
+ * the device; failures leave the code retryable. Unknown, expired, and already
171
+ * consumed codes all return `undefined`.
172
+ */
173
+ export interface PairingEnrollment {
174
+ redeemAndRegister(input: PairingEnrollmentInput): Promise<DeviceRecord | undefined>;
162
175
  }
163
176
  export interface NonceStore {
164
177
  issue(tenant: TenantId, deviceId: string): Promise<string>;
165
- /** `true` iff the nonce exists for this (tenant, device), is unexpired, and has not been consumed. Never mutates. */
166
- validate(tenant: TenantId, deviceId: string, nonce: string): Promise<boolean>;
167
- /** Consume the nonce. Called only after every other check on the request has passed. */
168
- markUsed(tenant: TenantId, nonce: string): Promise<void>;
178
+ /**
179
+ * Atomically consume a nonce only when it belongs to this exact (tenant,
180
+ * device), is unexpired, and has not already been consumed. Returns `true`
181
+ * for the sole winner; every other case returns `false`.
182
+ */
183
+ consumeIfValid(tenant: TenantId, deviceId: string, nonce: string): Promise<boolean>;
169
184
  }
170
185
  export interface InboundDedupStore {
171
186
  /**
172
- * `true` when `envelopeId` was already seen for this (tenant, device);
173
- * otherwise records it and returns `false`. Bounded per device — the wire is
174
- * at-least-once (§9); this makes processing at-most-once without an
175
- * unbounded set.
187
+ * Device-physical envelope ledger (`conn.hello` and facts with no Agent
188
+ * binding). `true` when `envelopeId` was already seen for this (tenant,
189
+ * device); otherwise records it and returns `false`.
176
190
  */
177
191
  checkAndRecord(tenant: TenantId, deviceId: string, envelopeId: string): Promise<boolean>;
192
+ /**
193
+ * Agent-bound envelope ledger. The AgentRef is required: a same-device
194
+ * second Agent must never resolve or suppress this Agent's completed fact.
195
+ * Retention remains bounded per exact (tenant, device, AgentRef).
196
+ */
197
+ checkAndRecordAgent(tenant: TenantId, deviceId: string, agentRef: AgentRef, envelopeId: string): Promise<boolean>;
178
198
  }
179
199
  export declare const TASK_ATTEMPT_STATUSES: readonly ['offered', 'claimed', 'running', 'cancel_requested', 'complete', 'failed', 'cancelled'];
180
200
  export type TaskAttemptStatus = (typeof TASK_ATTEMPT_STATUSES)[number];
201
+ /** The durable first-message reservation and, once present, its terminal disposition. */
202
+ export interface AgentMessageAdmission {
203
+ readonly messageId: string;
204
+ readonly payloadBody: string;
205
+ readonly terminalBody?: string;
206
+ }
181
207
  export interface TaskAttempt {
182
208
  readonly tenantId: TenantId;
183
209
  readonly taskId: string;
@@ -188,6 +214,40 @@ export interface TaskAttempt {
188
214
  /** Set by `task.claim`, and only by the first one. Legacy attempts remain claimable by any tenant device until then; strict Agent attempts are target-device bound before this field is consulted. */
189
215
  readonly ownerDeviceId?: string;
190
216
  readonly status: TaskAttemptStatus;
217
+ /**
218
+ * The runtime the CLAIMING daemon reported for itself on its own
219
+ * `task.claim` (`TaskClaimPayload.runtime`), snapshotted at the
220
+ * `offered -> claimed` transition and never written again — a redelivered or
221
+ * losing claim leaves it exactly as the winning claim left it.
222
+ *
223
+ * ADR-028 forbids cloud holding EXECUTION state (a live `running`/`thinking`
224
+ * phase, a turn, a PID, the current tool call) — quantities that keep
225
+ * changing while an attempt runs. This is not one: it is written once, at
226
+ * the moment ownership is decided, and is an attribution fact about the
227
+ * attempt. Byte-for-byte the same semantics as the reference server's
228
+ * `TaskSnapshot.claimedRuntime` (`packages/server/src/types.ts`).
229
+ *
230
+ * Absent for a daemon whose `task.claim` carried no `runtime` at all.
231
+ */
232
+ readonly claimedRuntime?: RuntimeId;
233
+ /**
234
+ * The capability block the CLAIMING adapter reported for ITSELF on that same
235
+ * `task.claim` (`TaskClaimPayload.capabilities`), snapshotted under exactly
236
+ * the same write-once rule as {@link TaskAttempt.claimedRuntime}.
237
+ *
238
+ * This is the ONLY input to the steer gate (`steer-control.ts`,
239
+ * `ByokCloud.steerTask`). The connection-level snapshot cloud keeps —
240
+ * `DeviceRecord.capabilities`, written from `conn.hello`
241
+ * (`inbound.ts`) — describes a device BUILD and never feeds it: a device can
242
+ * reconnect later with a different adapter set, while a task that is already
243
+ * running must keep being judged against what was true when it was claimed.
244
+ * Same single-source rule the reference server pins
245
+ * (`packages/server/src/__tests__/steer-runtime-capability-gate.test.ts`).
246
+ *
247
+ * Absent for a daemon that predates the field, which the gate reads as
248
+ * "unknown" and refuses — unknown is not supported.
249
+ */
250
+ readonly claimedRuntimeCapabilities?: RuntimeCapabilities;
191
251
  /** Runtime-reported terminal cause from the first winning terminal. */
192
252
  readonly terminalCause?: string;
193
253
  /** Durable host cancellation authority. Its presence outranks later device terminal receipts. */
@@ -197,6 +257,43 @@ export interface TaskAttempt {
197
257
  };
198
258
  readonly updatedAt: string;
199
259
  }
260
+ /**
261
+ * One bounded page request over a tenant's task attempts.
262
+ *
263
+ * Keyset-paged by `taskId` ASCENDING, deliberately, and NOT chronologically: an
264
+ * attempt carries no monotonic sequence, and `updatedAt` moves under an
265
+ * in-flight page (a status transition would re-order rows mid-walk and let a
266
+ * caller skip or repeat one). `taskId` is `task_<uuid>` — stable, unique per
267
+ * tenant, and never rewritten — so it is the only column that gives a walk the
268
+ * exactly-once property. The order is therefore arbitrary but TOTAL, which is
269
+ * what a paged read model needs; a caller that wants recency sorts a page
270
+ * itself.
271
+ *
272
+ * Adding a sequence column to get chronological order was rejected: it is a
273
+ * migration across every composition to serve a read-model nicety, and a second
274
+ * ordering authority to keep consistent with the row it decorates.
275
+ */
276
+ export interface TaskAttemptListQuery {
277
+ /** Maximum attempts in the page. Required and fail-closed: a non-positive or non-integer limit is rejected, never silently defaulted or clamped. */
278
+ readonly limit: number;
279
+ /**
280
+ * The `nextCursor` from the previous page — opaque to the caller, and an
281
+ * EXCLUSIVE lower bound on `taskId`. Absent starts at the beginning. A cursor
282
+ * naming a task that has since been deleted still resolves: it is a bound,
283
+ * not a lookup.
284
+ */
285
+ readonly cursor?: string;
286
+ }
287
+ export interface TaskAttemptPage {
288
+ readonly attempts: readonly TaskAttempt[];
289
+ /**
290
+ * Pass as the next request's `cursor`. ABSENT means this page is the end of
291
+ * the walk — not "start over" — so a caller stops on absence rather than on
292
+ * an empty page, and a page that exactly fills `limit` with nothing after it
293
+ * still terminates.
294
+ */
295
+ readonly nextCursor?: string;
296
+ }
200
297
  export interface TaskAttemptStore {
201
298
  /** Called when an offer is enqueued: records the pending attempt with no owner yet. */
202
299
  open(tenant: TenantId, input: {
@@ -216,13 +313,60 @@ export interface TaskAttemptStore {
216
313
  readonly attempt: TaskAttempt;
217
314
  readonly created: boolean;
218
315
  }>;
316
+ /**
317
+ * Atomically binds one message payload to a live task before an external
318
+ * consumer can run. `pending` is an existing exact reservation whose terminal
319
+ * receipt has not been recorded yet; callers retry the same product consumer
320
+ * contract so its own durable idempotency can reconcile a commit whose local
321
+ * finalization failed. Exact pending lookup precedes the live-task gate, while
322
+ * every new reservation remains lifecycle-gated.
323
+ */
324
+ reserveAgentMessage(tenant: TenantId, input: {
325
+ readonly taskId: string;
326
+ readonly deviceId: string;
327
+ readonly messageId: string;
328
+ readonly payloadBody: string;
329
+ }): Promise<'reserved' | 'pending' | 'rejected'>;
330
+ /** Read only an exact reservation; conflicting task/message bindings are not observable. */
331
+ readAgentMessage(tenant: TenantId, input: {
332
+ readonly taskId: string;
333
+ readonly deviceId: string;
334
+ readonly messageId: string;
335
+ readonly payloadBody: string;
336
+ }): Promise<AgentMessageAdmission | undefined>;
337
+ /** CAS the exact reservation to one immutable terminal disposition body. */
338
+ finalizeAgentMessage(tenant: TenantId, input: {
339
+ readonly taskId: string;
340
+ readonly deviceId: string;
341
+ readonly messageId: string;
342
+ readonly payloadBody: string;
343
+ readonly terminalBody: string;
344
+ }): Promise<AgentMessageAdmission | undefined>;
219
345
  get(tenant: TenantId, taskId: string): Promise<TaskAttempt | undefined>;
220
346
  /** Batch lookup used by mailbox projection; implementations must not turn one poll into N queries. */
221
347
  getMany(tenant: TenantId, taskIds: readonly string[]): Promise<readonly TaskAttempt[]>;
222
- /** First claim wins the ownership; a later claim by the same device is idempotent. No-op (returns `undefined`) for a task this tenant never offered. */
348
+ /**
349
+ * One bounded page of this tenant's attempts, keyset-paged by `taskId` — see
350
+ * {@link TaskAttemptListQuery} for why that key and not a timestamp.
351
+ * Implementations must read one page in one query (`taskId > cursor ORDER BY
352
+ * taskId LIMIT limit + 1`), never the whole tenant filtered in memory.
353
+ */
354
+ list(tenant: TenantId, query: TaskAttemptListQuery): Promise<TaskAttemptPage>;
355
+ /**
356
+ * First claim wins the ownership; a later claim by the same device is
357
+ * idempotent. No-op (returns `undefined`) for a task this tenant never
358
+ * offered.
359
+ *
360
+ * `runtime`/`capabilities` are the claiming daemon's self-report, written
361
+ * ONLY by the claim that actually wins the ownership CAS — an idempotent
362
+ * re-claim (and a losing one) must leave the recorded snapshot untouched,
363
+ * including when it carries a different or absent value.
364
+ */
223
365
  claim(tenant: TenantId, input: {
224
366
  readonly taskId: string;
225
367
  readonly deviceId: string;
368
+ readonly runtime?: RuntimeId;
369
+ readonly capabilities?: RuntimeCapabilities;
226
370
  }): Promise<TaskAttempt | undefined>;
227
371
  /** Record a lifecycle transition. No-op (returns `undefined`) for an unknown task — same shape as the reference server's per-type handlers. */
228
372
  recordStatus(tenant: TenantId, input: {
@@ -287,7 +431,8 @@ export interface AgentEgressStore {
287
431
  readonly record: AgentEgressRecord;
288
432
  readonly created: boolean;
289
433
  }>;
290
- get(tenant: TenantId, deviceId: string, eventId: string): Promise<AgentEgressRecord | undefined>;
434
+ /** Exact reliable fact lookup; device plus event id alone is ambiguous. */
435
+ get(tenant: TenantId, deviceId: string, agentRef: AgentRef, eventId: string): Promise<AgentEgressRecord | undefined>;
291
436
  }
292
437
  export interface ProofRequestReceipt {
293
438
  readonly tenantId: TenantId;
@@ -411,6 +556,8 @@ export interface CloudBlobStore {
411
556
  */
412
557
  export interface BlobContentProxy {
413
558
  verifySignedUrl(blobId: string, action: 'put' | 'get', sig: string, exp: number): Promise<boolean>;
559
+ /** Authoritative declared size for a signed PUT; no byte buffering precedes this lookup. */
560
+ expectedUploadBytes(blobId: string): Promise<bigint | undefined>;
414
561
  writeContent(blobId: string, data: Uint8Array): Promise<BlobWriteResult>;
415
562
  /** `undefined` = no such blob (404); a `{ok:false}` result = the blob exists but its bytes could not be proxied (502, distinguished by {@link BlobReadErrorCode}). */
416
563
  readContent(blobId: string): Promise<BlobReadResult | undefined>;
@@ -436,6 +583,7 @@ export interface CloudStores {
436
583
  readonly approvals: ApprovalTimelineStore;
437
584
  readonly devices: DeviceDirectory;
438
585
  readonly pairingCodes: PairingCodeStore;
586
+ readonly pairing: PairingEnrollment;
439
587
  readonly nonces: NonceStore;
440
588
  readonly dedup: InboundDedupStore;
441
589
  readonly tasks: TaskAttemptStore;
@@ -447,5 +595,5 @@ export interface CloudStores {
447
595
  readonly rateLimiter: InboundRateLimiter;
448
596
  }
449
597
  /** Names of every port in {@link CloudStores}, in contract order. */
450
- export declare const CLOUD_STORE_NAMES: readonly ['activity', 'approvals', 'devices', 'pairingCodes', 'nonces', 'dedup', 'tasks', 'cancellations', 'receipts', 'egress', 'proofReceipts', 'blobs', 'rateLimiter'];
598
+ export declare const CLOUD_STORE_NAMES: readonly ['activity', 'approvals', 'devices', 'pairingCodes', 'pairing', 'nonces', 'dedup', 'tasks', 'cancellations', 'receipts', 'egress', 'proofReceipts', 'blobs', 'rateLimiter'];
451
599
  export type CloudStoreName = (typeof CLOUD_STORE_NAMES)[number];
@@ -21,7 +21,8 @@
21
21
  * presigned blob calls) are deliberately absent from this surface: a
22
22
  * device-facing handler must not be able to reach them.
23
23
  */
24
- import { type BoardClaimInput, type BoardItem, type BoardItemInput, type BoardListQuery, type BoardPage, type BoardStatusUpdateInput, type BoardUnclaimInput, type CoreStores, type MailboxAdvanceCursorInput, type MailboxAppendInput, type MailboxCursorState, type MailboxMessage, type MailboxPage, type MailboxReadQuery, type Principal, type PresenceHint, type PresenceHintInput, type TenantReadiness, type StorageFinalizeInput, type StorageFinalizeResult, type StorageReservation, type StorageReservationInput, type TenantId } from '@byok-sdk/core';
24
+ import { type BoardClaimInput, type BoardItem, type BoardItemInput, type BoardListQuery, type BoardPage, type BoardStatusUpdateInput, type BoardUnclaimInput, type CoreStores, type MailboxAdvanceCursorInput, type MailboxAppendInput, type MailboxCursorState, type MailboxMessage, type MailboxPage, type MailboxReadQuery, type MailboxRecordDeliveryInput, type Principal, type PresenceHint, type PresenceHintInput, type TenantReadiness, type StorageFinalizeInput, type StorageFinalizeResult, type StorageReservation, type StorageReservationInput, type TenantId } from '@byok-sdk/core';
25
+ import type { AgentRef, RuntimeCapabilities, RuntimeId } from '@byok-sdk/protocol';
25
26
  import type { ActivityAppendInput, ActivityTail } from './activity';
26
27
  import type { ApprovalTimelineAppendInput, ApprovalTimelineTail } from './approval-timeline';
27
28
  import type { BlobObservation, CloudStores, DeviceRecord, AgentEgressRecord, RequestReceipt, TaskCancellationMutation, TaskCancellationRequest, TaskAttempt, TaskAttemptStatus } from './stores/ports';
@@ -29,6 +30,7 @@ export interface TenantBoundMailbox {
29
30
  append(input: MailboxAppendInput): Promise<MailboxMessage>;
30
31
  /** Pure read. Never advances the cursor — the daemon's next poll is the only ack. */
31
32
  readAfter(query: MailboxReadQuery): Promise<MailboxPage>;
33
+ recordDelivery(input: MailboxRecordDeliveryInput): Promise<MailboxCursorState>;
32
34
  advanceCursor(input: MailboxAdvanceCursorInput): Promise<MailboxCursorState>;
33
35
  readCursor(deviceId: string): Promise<MailboxCursorState>;
34
36
  }
@@ -77,11 +79,33 @@ export interface TenantBoundTaskAttempts {
77
79
  readonly attempt: TaskAttempt;
78
80
  readonly created: boolean;
79
81
  }>;
82
+ reserveAgentMessage(input: {
83
+ readonly taskId: string;
84
+ readonly deviceId: string;
85
+ readonly messageId: string;
86
+ readonly payloadBody: string;
87
+ }): Promise<'reserved' | 'pending' | 'rejected'>;
88
+ readAgentMessage(input: {
89
+ readonly taskId: string;
90
+ readonly deviceId: string;
91
+ readonly messageId: string;
92
+ readonly payloadBody: string;
93
+ }): Promise<import('./stores/ports').AgentMessageAdmission | undefined>;
94
+ finalizeAgentMessage(input: {
95
+ readonly taskId: string;
96
+ readonly deviceId: string;
97
+ readonly messageId: string;
98
+ readonly payloadBody: string;
99
+ readonly terminalBody: string;
100
+ }): Promise<import('./stores/ports').AgentMessageAdmission | undefined>;
80
101
  get(taskId: string): Promise<TaskAttempt | undefined>;
81
102
  getMany(taskIds: readonly string[]): Promise<readonly TaskAttempt[]>;
103
+ list(query: import('./stores/ports').TaskAttemptListQuery): Promise<import('./stores/ports').TaskAttemptPage>;
82
104
  claim(input: {
83
105
  readonly taskId: string;
84
106
  readonly deviceId: string;
107
+ readonly runtime?: RuntimeId;
108
+ readonly capabilities?: RuntimeCapabilities;
85
109
  }): Promise<TaskAttempt | undefined>;
86
110
  recordStatus(input: {
87
111
  readonly taskId: string;
@@ -94,7 +118,10 @@ export interface TenantBoundTaskCancellations {
94
118
  request(input: TaskCancellationRequest): Promise<TaskCancellationMutation | undefined>;
95
119
  }
96
120
  export interface TenantBoundDedup {
121
+ /** Device-physical envelope ledger. */
97
122
  checkAndRecord(deviceId: string, envelopeId: string): Promise<boolean>;
123
+ /** Strict Agent-bound envelope ledger. */
124
+ checkAndRecordAgent(deviceId: string, agentRef: AgentRef, envelopeId: string): Promise<boolean>;
98
125
  }
99
126
  export interface TenantBoundReceipts {
100
127
  record(input: {
@@ -111,7 +138,7 @@ export interface TenantBoundAgentEgress {
111
138
  readonly record: AgentEgressRecord;
112
139
  readonly created: boolean;
113
140
  }>;
114
- get(deviceId: string, eventId: string): Promise<AgentEgressRecord | undefined>;
141
+ get(deviceId: string, agentRef: AgentRef, eventId: string): Promise<AgentEgressRecord | undefined>;
115
142
  }
116
143
  export interface TenantBoundBlobs {
117
144
  createUpload(reservation: StorageReservation): Promise<{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@byok-sdk/cloud",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "BYOK SDK hosted device surface: stateless frozen-v1 HTTP handlers over tenant-first @byok-sdk/core ports",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,8 +44,8 @@
44
44
  "clean": "rm -rf dist"
45
45
  },
46
46
  "dependencies": {
47
- "@byok-sdk/core": "0.11.0",
48
- "@byok-sdk/protocol": "0.11.0",
47
+ "@byok-sdk/core": "0.13.0",
48
+ "@byok-sdk/protocol": "0.13.0",
49
49
  "hono": "^4.12.30",
50
50
  "zod": "^4.4.3"
51
51
  }