Skip to content

Commit 543b38b

Browse files
Nick Ficanoclaude
andcommitted
feat: surface the job authority descriptor to subscribers (§7.6)
The runtime now populates `budget` (current per-currency counters) on `job.subscribed`, alongside the `lease_constraints` it already sent, so an observing principal can render a job's authority surface — the expiry clock and budget gauge — without being the submitter. The cap is derivable from the lease's `cost.budget` pattern; subsequent `cost.budget.remaining` metric events keep the gauge live. The client's `JobSubscription` now exposes the full descriptor: `currentStatus`, `agent`, `lease`, `leaseConstraints`, `budget`, and (submitter-only) `credentials`. Credentials remain redacted for non-submitters per §14. Backward-compatible — only additive fields. Adds a runtime test asserting an observer receives budget + lease_constraints but never credentials. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b1cc6dd commit 543b38b

5 files changed

Lines changed: 154 additions & 0 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@agentruntimecontrolprotocol/runtime": minor
3+
"@agentruntimecontrolprotocol/client": minor
4+
---
5+
6+
Surface a job's authority descriptor to subscribers (§7.6).
7+
8+
The runtime now populates `budget` (current per-currency counters) on
9+
`job.subscribed`, alongside the `lease_constraints` it already sent, so an
10+
observing principal can render a job's authority surface — the expiry clock
11+
and budget gauge — without being the job's submitter. The cap is derivable
12+
from the lease's `cost.budget` pattern; subsequent `cost.budget.remaining`
13+
metric events keep the gauge live.
14+
15+
The client's `JobSubscription` now exposes the full descriptor:
16+
`currentStatus`, `agent`, `lease`, `leaseConstraints`, `budget`, and
17+
(submitter-only) `credentials`. Credentials remain redacted for non-submitters
18+
per §14. Backward-compatible — only additive fields.

packages/client/src/client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,12 @@ export class ARCPClient {
660660
const ack = await deferred.promise;
661661
return {
662662
jobId,
663+
currentStatus: ack.current_status,
664+
agent: ack.agent,
665+
lease: ack.lease,
666+
leaseConstraints: ack.lease_constraints,
667+
budget: ack.budget,
668+
credentials: ack.credentials,
663669
subscribedFrom: ack.subscribed_from,
664670
replayed: ack.replayed,
665671
unsubscribe: () => this.unsubscribe(jobId, sessionId),

packages/client/src/types.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
ClientIdentity,
88
Envelope,
99
JobResultPayload,
10+
JobStateName,
1011
Lease,
1112
LeaseConstraints,
1213
Credential,
@@ -125,6 +126,26 @@ export interface JobHandle {
125126
*/
126127
export interface JobSubscription {
127128
readonly jobId: JobId;
129+
/** Job status at subscription time (§7.6). */
130+
readonly currentStatus: JobStateName;
131+
/** Resolved `name@version` (or bare name) the runtime is running. */
132+
readonly agent: string;
133+
/** Effective capability grants (§9.1). */
134+
readonly lease: Lease;
135+
/** v1.1 §9.5 — echoed lease constraints (currently `expires_at`), if any. */
136+
readonly leaseConstraints: LeaseConstraints | undefined;
137+
/**
138+
* v1.1 §9.6 — current per-currency budget counters at subscription time,
139+
* present when `cost.budget` is in the lease. The cap is derivable from the
140+
* lease's `cost.budget` pattern; subsequent `cost.budget.remaining` metric
141+
* events keep this live.
142+
*/
143+
readonly budget: Readonly<Record<string, number>> | undefined;
144+
/**
145+
* v1.1 §9.8 — provisioned credentials, present ONLY when this subscriber is
146+
* the job's original submitter. Redacted for all other observers (§14).
147+
*/
148+
readonly credentials: readonly Credential[] | undefined;
128149
readonly subscribedFrom: number;
129150
readonly replayed: boolean;
130151
unsubscribe(): Promise<void>;

packages/runtime/src/server-subscribe.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,16 @@ function buildSubscribedPayload(args: {
219219
subscriberPrincipal !== undefined &&
220220
job.submitterPrincipal === subscriberPrincipal;
221221

222+
// §9.6 — current per-currency budget counters, so an observer joining
223+
// mid-job sees the live remaining (the cap is derivable from the lease's
224+
// `cost.budget` pattern). Empty-map guard avoids an empty `budget: {}`.
225+
const budget: Record<string, number> = {};
226+
let hasBudget = false;
227+
for (const [currency, remaining] of job.budget.entries()) {
228+
budget[currency] = remaining;
229+
hasBudget = true;
230+
}
231+
222232
return {
223233
job_id: job.jobId,
224234
current_status: job.state,
@@ -227,6 +237,7 @@ function buildSubscribedPayload(args: {
227237
...(job.leaseConstraints === undefined
228238
? {}
229239
: { lease_constraints: job.leaseConstraints }),
240+
...(hasBudget ? { budget } : {}),
230241
parent_job_id: job.parentJobId ?? null,
231242
...(job.traceId === undefined ? {} : { trace_id: job.traceId }),
232243
subscribed_from: subscribedFrom,

packages/runtime/test/provisioned-credentials.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,4 +662,102 @@ describe("credential confidentiality in job.subscribed (§14)", () => {
662662
await bobClient.close();
663663
await server.close();
664664
});
665+
666+
it("observer receives budget + lease_constraints on job.subscribed (§7.6)", async () => {
667+
const server = new ARCPServer({
668+
runtime: TEST_RUNTIME,
669+
capabilities: TEST_CAPABILITIES,
670+
bearer: new StaticBearerVerifier(
671+
new Map([
672+
["tok-alice", { principal: "alice" }],
673+
["tok-bob", { principal: "bob" }],
674+
]),
675+
),
676+
// Cross-principal subscription so bob can observe alice's job.
677+
jobAuthorizationPolicy: () => true,
678+
logger: silentLogger,
679+
});
680+
server.registerAgent("slow-noop", async () => {
681+
await new Promise((r) => setTimeout(r, 200));
682+
return null;
683+
});
684+
685+
const [aliceClient, aliceServerSide] = pairMemoryTransports();
686+
const [bobClient, bobServerSide] = pairMemoryTransports();
687+
server.accept(aliceServerSide);
688+
server.accept(bobServerSide);
689+
const aliceCollector = new FrameCollector(aliceClient);
690+
const bobCollector = new FrameCollector(bobClient);
691+
692+
// Alice negotiates cost.budget + lease_expires_at so the runtime
693+
// initializes/echoes those bounds.
694+
await aliceClient.send({
695+
arcp: PROTOCOL_VERSION,
696+
id: "msg-hello",
697+
type: "session.hello",
698+
payload: {
699+
client: { name: "test-client", version: "0.0.1" },
700+
capabilities: {
701+
encodings: ["json"],
702+
features: ["subscribe", "cost.budget", "lease_expires_at"],
703+
},
704+
auth: { scheme: "bearer", token: "tok-alice" },
705+
},
706+
});
707+
const aliceSessionId = (
708+
await aliceCollector.waitFor((f) => f["type"] === "session.welcome")
709+
).find((f) => f["type"] === "session.welcome")!["session_id"] as string;
710+
711+
const expiresAt = new Date(Date.now() + 15 * 60_000).toISOString();
712+
await aliceClient.send({
713+
arcp: PROTOCOL_VERSION,
714+
id: "msg-submit-budget",
715+
type: "job.submit",
716+
session_id: aliceSessionId,
717+
payload: {
718+
agent: "slow-noop",
719+
input: {},
720+
lease_request: { "cost.budget": ["USD:5.00"] },
721+
lease_constraints: { expires_at: expiresAt },
722+
},
723+
});
724+
const jobId = (
725+
(await aliceCollector.waitFor((f) => f["type"] === "job.accepted")).find(
726+
(f) => f["type"] === "job.accepted",
727+
)!["payload"] as Record<string, unknown>
728+
)["job_id"] as string;
729+
730+
await bobClient.send(helloFrame("tok-bob"));
731+
const bobSessionId = (
732+
await bobCollector.waitFor((f) => f["type"] === "session.welcome")
733+
).find((f) => f["type"] === "session.welcome")!["session_id"] as string;
734+
735+
await bobClient.send({
736+
arcp: PROTOCOL_VERSION,
737+
id: "msg-sub-budget",
738+
type: "job.subscribe",
739+
session_id: bobSessionId,
740+
payload: { job_id: jobId },
741+
});
742+
const payload = (
743+
await bobCollector.waitFor((f) => f["type"] === "job.subscribed")
744+
).find((f) => f["type"] === "job.subscribed")!["payload"] as Record<
745+
string,
746+
unknown
747+
>;
748+
749+
// Observer (bob) gets the non-secret authority bounds...
750+
expect(payload["budget"]).toEqual({ USD: 5 });
751+
expect(
752+
(payload["lease_constraints"] as Record<string, unknown> | undefined)?.[
753+
"expires_at"
754+
],
755+
).toBe(expiresAt);
756+
// ...but never credentials.
757+
expect(payload["credentials"]).toBeUndefined();
758+
759+
await aliceClient.close();
760+
await bobClient.close();
761+
await server.close();
762+
});
665763
});

0 commit comments

Comments
 (0)