You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Implement lease-bound provisioned credentials, as specified in ARCP v1.1 §9.8, and the supporting model.use lease capability (§9.7).
This is a runtime-side feature: when a job is accepted, the runtime mints one or more short-lived, scope-restricted credentials at an upstream cost-bearing service (LLM gateway, search API, paid SaaS), embeds them in job.accepted.payload.credentials, and revokes them on job termination. The upstream becomes the cost / model-tier enforcement backstop instead of the agent self-policing.
The wire shape is vendor-neutral. LiteLLM's /key/generate is the canonical reference backend (one-shot virtual key with max_budget and allowed_models matched to the lease, revoked via /key/delete), but the SDK must not bake that vendor in.
Scope
Lease grammar
Parse model.use capability patterns from lease_request.
Enforce model.use on any LLM invocation the runtime is in the path of (PERMISSION_DENIED on miss).
Extend lease subsetting (§9.4) to cover model.use: a child's permitted model set must be a subset of the parent's. Reject with LEASE_SUBSET_VIOLATION otherwise.
When cost.budget is enforced through a provisioned credential, translate upstream budget-exhausted errors into BUDGET_EXHAUSTED at the ARCP boundary (§9.6).
Provisioned credentials
Define a CredentialProvisioner interface (or this language's idiomatic equivalent) with issue(lease, jobContext) -> Credential[] and revoke(credentialId). Vendor-specific implementations (e.g., LiteLLM, Anthropic admin keys, custom) live as plug-ins, not in core.
Wire the provisioner into job acceptance: call issue after the lease is finalized, attach the returned credentials array to job.accepted.payload, before the message is sent.
Each credential matches the wire shape in §9.8.1: {id, scheme, value, endpoint, profile?, constraints?}. scheme: "bearer" is the minimum; other schemes are optional.
Bake into each credential, at minimum: cost.budget → upstream spend cap; model.use → upstream allowed-model list; lease_constraints.expires_at → credential TTL.
On terminal state (success, error, cancelled, timed_out), call revoke for every outstanding credential. Best-effort with retry on transient failure; persist outstanding credential IDs so revocation survives runtime restarts.
Support credential rotation: when the provisioner re-issues mid-job, emit a status event with phase: "credential_rotated" carrying {id, value}. Revoke the prior value promptly.
Delegated jobs (§10) receive child credentials constrained at or below the child's lease. Child credentials revoke with the child, not the parent.
Feature negotiation
Advertise provisioned_credentials and model.use in session.welcome.payload.capabilities.features only when a provisioner is configured.
Accept both flags from session.hello.payload.capabilities.features and respect the intersection rule (§6.2).
Security
Treat credential value as a secret throughout: no logs, no telemetry export, no echo to subscribers.
Redact credentials from any session.list_jobs / introspection surface presented to a principal that is not the job's submitter (§14 "Credential confidentiality").
Reject configurations that advertise provisioned_credentials without a durable revocation path (§14 "Credential revocation reliability").
Issue credentials only over authenticated, encrypted transports.
Unit: BUDGET_EXHAUSTED translation from a stubbed upstream error.
Integration: in-memory CredentialProvisioner that returns deterministic credentials; verify they appear in job.accepted, are absent from cross-principal introspection, and revoke is called on every terminal state including cancelled and timed_out.
Integration: credential rotation emits status: credential_rotated and revokes the prior value.
Integration: delegated job receives a child credential whose constraints are a strict subset.
Docs / examples
Update docs/guides/leases.md (or equivalent) with model.use semantics and the credential lifecycle.
Add a recipe / example demonstrating a LiteLLM-backed provisioner. Keep it in examples/ or recipes/ so it's clearly a plug-in, not core.
Update CONFORMANCE.md to claim model.use and provisioned_credentials once the work lands.
Non-goals
Defining a credential-scheme registry beyond bearer. Other schemes (basic, signed_url, etc.) are deferred until a concrete use case appears.
Predictive cost accounting. The upstream is authoritative; the runtime translates errors, it does not estimate.
Built-in adapters for specific vendors beyond a documented reference plug-in. Core ships the interface only.
The repo is a pnpm workspace. Package layout: packages/core, packages/client, packages/runtime, packages/sdk, plus middleware adapters under packages/middleware/*. Tests use vitest. Schemas use effect/Schema. All paths below are absolute from the repo root.
packages/core/src/messages/lease-schema.ts — add "model.use" to RESERVED_CAPABILITY_NAMES (currently lists six names; this becomes seven). Update the JSDoc on RESERVED_CAPABILITY_NAMES and the ReservedCapabilityName type comment.
packages/core/src/messages/execution.ts — extend JobAcceptedPayloadSchema with credentials?: Credential[]. Extend JobSubscribedPayloadSchema likewise. Import from ./credentials.js.
packages/core/src/messages/index.ts — re-export from ./credentials.js (already uses export *; just make the new file is picked up via index.ts aggregation if needed — check that the new file isn't excluded).
packages/core/src/version.ts — append "model.use" and "provisioned_credentials" to V1_1_FEATURES.
packages/runtime/src/credential-provisioner.ts (new) — CredentialProvisioner interface, CredentialIssueContext type, IssuedCredential type (server-side bookkeeping that wraps the wire Credential plus a provisionerId for revoke).
packages/runtime/src/credential-store.ts (new) — CredentialStore interface and InMemoryCredentialStore implementation (parallels IdempotencyStore / ResumeStore in stores.ts). Persists {jobId, credentialId, provisionerId, issuedAt} so revocation survives restart in pluggable backends.
packages/runtime/src/lease.ts — extend validateLeaseShape to accept model.use (it already accepts reserved names via isValidCapabilityName, so the change is purely additive in the core schema). Verify isLeaseSubset already handles a model.use capability as a plain glob-list subset (no special case needed since model identifiers are non-slash strings; the existing patternSubsumes works). Add explicit JSDoc noting model.use per §9.4.
packages/runtime/src/types.ts — add credentialProvisioner?: CredentialProvisioner and credentialStore?: CredentialStore fields to ARCPServerOptions. Add credentials?: readonly IssuedCredential[] to JobOptions.
packages/runtime/src/job.ts — add credentials: IssuedCredential[] field on Job. In emitAccepted, include credentials: this.credentials.map(toWireCredential) when the array is non-empty. Add applyCredentialRotation(prev, next) and revokeAll() helpers.
packages/runtime/src/job-runner.ts — after constructJob, call provisioner.issue(...) (when configured), assign the issued credentials onto the Job, then call job.emitAccepted(). In runHandler's finally block (after ctx.jobs.retire(...)), call job.revokeAll() regardless of terminal state. Mirror in createDelegateJob so delegated child jobs get their own credentials.
packages/runtime/src/server.ts — in makeNegotiatedCapabilities / acceptFreshSession, only advertise "provisioned_credentials" and "model.use" when options.credentialProvisioner !== undefined (i.e., filter advertisedFeatures here). Validate at server construction that, if credentialProvisioner is configured, credentialStore is also configured (durable revocation path requirement §14).
packages/runtime/src/server-subscribe.ts — buildSubscribedPayload MUST NOT include credentials for subscribers that are not the submitter; gate by defaultJobAuthorizationPolicy plus a strict "same principal" check. buildListJobsCandidates similarly excludes credentials from any non-submitter view (it already excludes them by omission — confirm by reading the function; just add the field cautiously).
packages/runtime/src/index.ts — re-export CredentialProvisioner, CredentialIssueContext, IssuedCredential, CredentialStore, InMemoryCredentialStore, and the new error-translation helper.
@arcp/client — surface credentials on JobHandle.
packages/client/src/client-handle.ts — add credentials?: readonly Credential[] to InvocationState and JobHandle. Plumb through makeHandleFromInvocation.
packages/client/src/client.ts — on inbound job.accepted, copy env.payload.credentials (if present) into the invocation state.
Tests — vitest, alongside the existing files.
packages/runtime/test/lease.test.ts — add model.use glob, subset, and rejection tests.
packages/runtime/test/credential-provisioner.test.ts (new) — unit tests for in-memory provisioner lifecycle.
packages/sdk/test/integration/provisioned-credentials.test.ts (new) — end-to-end tests covering all integration acceptance items.
Docs — markdown.
docs/guides/leases.md — add a model.use section after the namespace table.
docs/guides/credentials.md (new) — full guide for the credential lifecycle, with the LiteLLM mapping table.
examples/provisioned-credentials/ (new) — server.ts, client.ts, README.md. Use an in-memory MockProvisioner to keep the example free of an external dependency.
recipes/litellm-credentials/ (new) — full LiteLLM-backed recipe with server.ts, client.ts, README.md. Wire to /key/generate and /key/delete over HTTP.
recipes/README.md — link the new recipe.
examples/README.md — link the new example.
CONFORMANCE.md — claim model.use and provisioned_credentials in the §9 and §6.2 tables.
Public API additions
// packages/core/src/messages/credentials.ts (new)import{Schema}from"effect";import{LeaseConstraintsSchema}from"./lease-schema.js";/** v1.1 §9.8.1 — per-credential constraints echoed on the wire. */exportconstCredentialConstraintsSchema=Schema.Struct({expires_at: Schema.optional(Schema.String.pipe(Schema.nonEmptyString())),allowed_models: Schema.optional(Schema.mutable(Schema.Array(Schema.String.pipe(Schema.nonEmptyString()))),),max_spend: Schema.optional(Schema.Struct({currency: Schema.String.pipe(Schema.nonEmptyString()),amount: Schema.Number.pipe(Schema.nonNegative()),}),),});exporttypeCredentialConstraints=Schema.Schema.Type<typeofCredentialConstraintsSchema>;/** v1.1 §9.8.1 — wire shape of one issued credential. */exportconstCredentialSchema=Schema.Struct({id: Schema.String.pipe(Schema.nonEmptyString()),scheme: Schema.Literal("bearer"),value: Schema.String.pipe(Schema.nonEmptyString()),endpoint: Schema.String.pipe(Schema.nonEmptyString()),profile: Schema.optional(Schema.String.pipe(Schema.nonEmptyString())),constraints: Schema.optional(CredentialConstraintsSchema),});exporttypeCredential=Schema.Schema.Type<typeofCredentialSchema>;
// packages/runtime/src/credential-provisioner.ts (new)importtype{Credential,Lease,LeaseConstraints}from"@arcp/core/messages";importtype{JobId,TraceId}from"@arcp/core";/** Server-side bookkeeping wrapper around a wire `Credential`. */exportinterfaceIssuedCredential{/** Wire-form credential — exactly what is embedded in `job.accepted`. */readonlywire: Credential;/** Opaque ID this provisioner uses to revoke. */readonlyprovisionerId: string;}exportinterfaceCredentialIssueContext{readonlyjobId: JobId;readonlyparentJobId?: JobId|undefined;readonlylease: Lease;readonlyleaseConstraints: LeaseConstraints|undefined;readonlyinitialBudget: ReadonlyMap<string,number>;readonlyprincipal: string|undefined;readonlytraceId: TraceId|undefined;}/** v1.1 §9.8 — pluggable upstream credential minter. */exportinterfaceCredentialProvisioner{issue(ctx: CredentialIssueContext): Promise<readonlyIssuedCredential[]>;revoke(provisionerId: string): Promise<void>;}/** * Translate a vendor error to an ARCP `BudgetExhaustedError`. Plug-ins call * this when the upstream signals a spend cap hit. */exportfunctiontoBudgetExhausted(error: unknown,details?: Record<string,unknown>): never;
// packages/runtime/src/types.ts — extensionsexportinterfaceARCPServerOptions{// ...existing fields.../** v1.1 §9.8 — pluggable credential minter. When set, the runtime advertises `provisioned_credentials`. */credentialProvisioner?: CredentialProvisioner;/** v1.1 §9.8 — durable revocation tracking. REQUIRED when `credentialProvisioner` is set. */credentialStore?: CredentialStore;}exportinterfaceJobOptions{// ...existing fields...credentials?: readonlyIssuedCredential[];}
// packages/client/src/types.ts — JobHandle extensionexportinterfaceJobHandle{// ...existing fields.../** v1.1 §9.8 — credentials minted for this job, if the runtime advertised `provisioned_credentials`. */readonlycredentials: readonlyCredential[]|undefined;}
Step-by-step changes
core: feature flags — In packages/core/src/version.ts, append "model.use" and "provisioned_credentials" to V1_1_FEATURES. Do NOT reorder existing entries (downstream tests assert sorted equality).
core: capability name — In packages/core/src/messages/lease-schema.ts, add "model.use" to RESERVED_CAPABILITY_NAMES. Update JSDoc on the constant and the ReservedCapabilityName doc comment.
core: credential wire schema — Create packages/core/src/messages/credentials.ts with CredentialConstraintsSchema, CredentialSchema, and the inferred types. Use the same effect/Schema style as lease-schema.ts.
core: extend JobAcceptedPayloadSchema — In packages/core/src/messages/execution.ts, import CredentialSchema from ./credentials.js and add credentials: Schema.optional(Schema.mutable(Schema.Array(CredentialSchema))) to JobAcceptedPayloadSchema. Mirror on JobSubscribedPayloadSchema (so subscribers who pass authorization see it).
core: re-exports — Ensure packages/core/src/messages/index.ts exports the new file (it uses export * for the directory, so creating the file may need a sibling re-export — check by running typecheck). If the index does not automatically pick up credentials.ts, add export * from "./credentials.js".
runtime: provisioner interface — Create packages/runtime/src/credential-provisioner.ts. Define CredentialProvisioner, IssuedCredential, CredentialIssueContext, and a toBudgetExhausted(error, details?) helper that constructs a BudgetExhaustedError (import { BudgetExhaustedError } from "@arcp/core/errors").
runtime: credential store — Create packages/runtime/src/credential-store.ts. Implement InMemoryCredentialStore keyed by jobId → Map<credentialId, entry>. Provide add, removeByJob (returns and clears the per-job list), listOutstanding.
runtime: types extension — In packages/runtime/src/types.ts, add credentialProvisioner and credentialStore to ARCPServerOptions. Add credentials?: readonly IssuedCredential[] to JobOptions. Add a re-export note (no value re-export needed; types are pulled by index.ts).
runtime: Job class — In packages/runtime/src/job.ts:
Add public credentials: IssuedCredential[] = [] field.
In emitAccepted, include credentials: this.credentials.map((c) => c.wire) in the payload when this.credentials.length > 0.
Add public async rotateCredential(prevId: string, next: IssuedCredential): Promise<void> — replaces in-place, emits a status event with phase: "credential_rotated" body { id: next.wire.id, value: next.wire.value } (DO NOT log the value).
Add public async revokeAll(provisioner: CredentialProvisioner | undefined, store: CredentialStore | undefined, logger: Logger): Promise<void> — iterates credentials, calls provisioner.revoke(c.provisionerId) with one-retry-on-throw, removes entries from store. Logger entries MUST omit c.wire.value.
runtime: server option validation — In packages/runtime/src/server.tsARCPServer constructor, if options.credentialProvisioner !== undefined && options.credentialStore === undefined, throw new InvalidRequestError("credentialProvisioner requires credentialStore for durable revocation (§14)").
runtime: feature gating — In packages/runtime/src/server.ts, modify advertisedFeatures (or the call site in makeNegotiatedCapabilities) so that "provisioned_credentials" and "model.use" are filtered OUT when options.credentialProvisioner === undefined. Keep them in when configured.
runtime: issue on submit — In packages/runtime/src/job-runner.tsacceptAndDispatchSubmit:
After constructJob(...) and before job.emitAccepted(), if this.server.options.credentialProvisioner is defined, call:
runtime: delegated jobs — In createDelegateJob, after constructDelegateChild and before child.emitAccepted(), issue child credentials via the same path as #12, passing parentJobId: parent.jobId. The provisioner is expected to subset constraints; the runtime does not re-validate the value-bearing payload, only that the lease passed to issue is a strict subset (which is already enforced by validateDelegateLease).
runtime: subscriber redaction — In packages/runtime/src/server-subscribe.tsbuildSubscribedPayload, do NOT emit credentials when the subscriber's principal is not the job's submitter. Inline check: const isSubmitter = ctx.state.identity?.principal === job.submitterPrincipal; and only spread credentials when isSubmitter && job.credentials.length > 0. Pass ctx as a new arg into buildSubscribedPayload (already available at the caller).
runtime: list-jobs scoping — In packages/runtime/src/server-subscribe.tsbuildListJobsCandidates, the existing entry shape (JobListEntry) does not include credentials — confirm and leave as-is (it is already redacted by omission). Add a JSDoc comment noting §14 compliance.
runtime: index re-exports — In packages/runtime/src/index.ts, add named exports for CredentialProvisioner, CredentialIssueContext, IssuedCredential, CredentialStore, InMemoryCredentialStore, and toBudgetExhausted.
client: handle plumbing — In packages/client/src/client-handle.ts:
Add credentials: Credential[] | undefined to InvocationState (initialize undefined).
Add credentials getter to makeHandleFromInvocation.
In packages/client/src/client.ts:
In the job.accepted route, copy env.payload.credentials (if present) onto invocation.credentials.
In packages/client/src/types.ts:
Add readonly credentials: readonly Credential[] | undefined; to JobHandle, importing Credential from @arcp/core/messages.
sdk: re-exports — In packages/sdk/src/runtime.ts (or index.ts), re-export the new symbols (CredentialProvisioner, IssuedCredential, CredentialIssueContext, CredentialStore, InMemoryCredentialStore, toBudgetExhausted, Credential, CredentialConstraints). Match the existing pattern (see how validateLeaseOp is re-exported).
CONFORMANCE — Add rows to the §6.2 features table (provisioned_credentials, model.use) and a new §9.7/§9.8 subsection in CONFORMANCE.md, pointing at the new files.
Docs guide — Update docs/guides/leases.md: add model.use to the namespace table (gates LLM model invocation). Create docs/guides/credentials.md covering issue/revoke/rotate semantics and the LiteLLM mapping table (lease cost.budget → max_budget, model.use → allowed_models, lease_constraints.expires_at → key TTL).
Example — Create examples/provisioned-credentials/ with server.ts, client.ts, README.md. Use an in-memory MockProvisioner that returns deterministic { id, value: "mock-key-${i}", endpoint: "http://localhost/api" }. Mirror the layout of examples/cost-budget/.
Recipe — Create recipes/litellm-credentials/ with server.ts, client.ts, README.md. Implement LiteLLMProvisioner calling POST /key/generate (body: { max_budget, allowed_models, duration }) and POST /key/delete (body: { keys: [...] }). Use fetch (Node 22+). The recipe imports CredentialProvisioner from @arcp/sdk. Add link to recipes/README.md.
InMemoryCredentialStore.add then removeByJob returns and clears the right entries.
toBudgetExhausted(new Error("upstream 402")) throws BudgetExhaustedError with code "BUDGET_EXHAUSTED", retryable: false.
A mock provisioner that returns one credential is invoked by issue and revoke exactly once each.
packages/sdk/test/integration/provisioned-credentials.test.ts (new), using makePairedHarness from packages/sdk/test/helpers/fixtures.ts:
With a MockProvisioner configured, a submitted job receives handle.credentials of length 1 with the expected id, endpoint, and a redacted-shape value.
job.accepted.payload.credentials[0].constraints.allowed_models matches the lease's model.use patterns.
Cancel a job (handle.cancel("user")) → assert provisioner.revoke was called for that credential id.
Force a TIMEOUT via max_runtime_sec: 0.1 and a long handler → assert revoke fires.
A delegated job uses a subset model list; assert the child's credential's allowed_models is a strict subset of the parent's.
Server WITHOUT a provisioner: assert client.negotiatedFeatures does not include "provisioned_credentials" or "model.use".
Rotation: provisioner re-issues mid-job → a status event with phase: "credential_rotated" is observed on the client; the previous provisionerId was passed to revoke.
Cross-principal subscribe: when a second principal subscribes (via a custom jobAuthorizationPolicy that allows it), the job.subscribed.payload MUST NOT carry credentials.
Verification commands
Run from /Users/nficano/code/arpc/typescript-sdk:
pnpm install
pnpm -F @arcp/core typecheck && pnpm -F @arcp/core test
pnpm -F @arcp/runtime typecheck && pnpm -F @arcp/runtime test
pnpm -F @arcp/client typecheck && pnpm -F @arcp/client test
pnpm -F @arcp/sdk typecheck && pnpm -F @arcp/sdk test
pnpm typecheck
pnpm test
pnpm lint
pnpm check:cycles
Target a single test file while iterating:
pnpm -F @arcp/sdk exec vitest run test/integration/provisioned-credentials.test.ts
pnpm -F @arcp/runtime exec vitest run test/credential-provisioner.test.ts
Acceptance
Lease grammar:
[task] model.use added to RESERVED_CAPABILITY_NAMES in packages/core/src/messages/lease-schema.ts.
[task] validateLeaseShape accepts model.use patterns; validateLeaseOp enforces them with PERMISSION_DENIED on miss.
[task] isLeaseSubset rejects child model.use patterns broader than the parent with LEASE_SUBSET_VIOLATION.
[task] toBudgetExhausted(error) translates an upstream budget error to BudgetExhaustedError (packages/runtime/src/credential-provisioner.ts).
Provisioned credentials:
[task] CredentialProvisioner interface lives in packages/runtime/src/credential-provisioner.ts with issue and revoke.
[task] JobRunner.acceptAndDispatchSubmit calls issue after lease finalization, before job.emitAccepted.
[task] runHandler finally-block calls job.revokeAll for all terminal states; InMemoryCredentialStore clears the per-job entries.
[task] Rotation API on Job (rotateCredential) emits status: credential_rotated and revokes the prior provisionerId.
[task] createDelegateJob issues child credentials with a subset lease; child credentials revoke with the child.
Feature negotiation:
[task] provisioned_credentials and model.use are added to V1_1_FEATURES and filtered out of advertisedFeatures when no provisioner is configured.
[task] Negotiation intersection (intersectFeatures) excludes the feature when either peer omits it.
Security:
[task] revokeAll logger calls do not include wire.value (verify by code review and a regex assertion in the credential-provisioner test against the captured log output).
[task] buildSubscribedPayload and buildListJobsCandidates redact credentials from non-submitter views.
[task] ARCPServer constructor throws when credentialProvisioner is set without credentialStore.
Tests:
[task] Lease unit tests cover model.use parse + subset.
[task] BUDGET_EXHAUSTED translation unit test.
[task] Integration tests cover: issue on submit, redact on subscribe, revoke on every terminal state, rotation, delegation subset, feature opt-out.
Docs / examples:
[task] docs/guides/leases.md describes model.use.
[task] docs/guides/credentials.md describes the lifecycle.
[task] examples/provisioned-credentials/ with vendor-neutral MockProvisioner.
[task] recipes/litellm-credentials/ wires a real LiteLLM backend.
[task] CONFORMANCE.md claims model.use and provisioned_credentials.
Goal
Implement lease-bound provisioned credentials, as specified in ARCP v1.1 §9.8, and the supporting
model.uselease capability (§9.7).This is a runtime-side feature: when a job is accepted, the runtime mints one or more short-lived, scope-restricted credentials at an upstream cost-bearing service (LLM gateway, search API, paid SaaS), embeds them in
job.accepted.payload.credentials, and revokes them on job termination. The upstream becomes the cost / model-tier enforcement backstop instead of the agent self-policing.The wire shape is vendor-neutral. LiteLLM's
/key/generateis the canonical reference backend (one-shot virtual key withmax_budgetandallowed_modelsmatched to the lease, revoked via/key/delete), but the SDK must not bake that vendor in.Scope
Lease grammar
model.usecapability patterns fromlease_request.model.useon any LLM invocation the runtime is in the path of (PERMISSION_DENIEDon miss).model.use: a child's permitted model set must be a subset of the parent's. Reject withLEASE_SUBSET_VIOLATIONotherwise.cost.budgetis enforced through a provisioned credential, translate upstream budget-exhausted errors intoBUDGET_EXHAUSTEDat the ARCP boundary (§9.6).Provisioned credentials
CredentialProvisionerinterface (or this language's idiomatic equivalent) withissue(lease, jobContext) -> Credential[]andrevoke(credentialId). Vendor-specific implementations (e.g., LiteLLM, Anthropic admin keys, custom) live as plug-ins, not in core.issueafter the lease is finalized, attach the returnedcredentialsarray tojob.accepted.payload, before the message is sent.{id, scheme, value, endpoint, profile?, constraints?}.scheme: "bearer"is the minimum; other schemes are optional.cost.budget→ upstream spend cap;model.use→ upstream allowed-model list;lease_constraints.expires_at→ credential TTL.success,error,cancelled,timed_out), callrevokefor every outstanding credential. Best-effort with retry on transient failure; persist outstanding credential IDs so revocation survives runtime restarts.statusevent withphase: "credential_rotated"carrying{id, value}. Revoke the prior value promptly.Feature negotiation
provisioned_credentialsandmodel.useinsession.welcome.payload.capabilities.featuresonly when a provisioner is configured.session.hello.payload.capabilities.featuresand respect the intersection rule (§6.2).Security
valueas a secret throughout: no logs, no telemetry export, no echo to subscribers.credentialsfrom anysession.list_jobs/ introspection surface presented to a principal that is not the job's submitter (§14 "Credential confidentiality").provisioned_credentialswithout a durable revocation path (§14 "Credential revocation reliability").Tests
model.usepatterns; subsetting rejects expanded model sets.BUDGET_EXHAUSTEDtranslation from a stubbed upstream error.CredentialProvisionerthat returns deterministic credentials; verify they appear injob.accepted, are absent from cross-principal introspection, andrevokeis called on every terminal state includingcancelledandtimed_out.status: credential_rotatedand revokes the prior value.Docs / examples
docs/guides/leases.md(or equivalent) withmodel.usesemantics and the credential lifecycle.examples/orrecipes/so it's clearly a plug-in, not core.CONFORMANCE.mdto claimmodel.useandprovisioned_credentialsonce the work lands.Non-goals
bearer. Other schemes (basic,signed_url, etc.) are deferred until a concrete use case appears.References
job.accepted/key/generate+/key/delete.Implementation prompt
The repo is a pnpm workspace. Package layout:
packages/core,packages/client,packages/runtime,packages/sdk, plus middleware adapters underpackages/middleware/*. Tests use vitest. Schemas useeffect/Schema. All paths below are absolute from the repo root.Files to touch
@arcp/core— wire shapes, error codes, feature flags, capability namespace registry.packages/core/src/messages/lease-schema.ts— add"model.use"toRESERVED_CAPABILITY_NAMES(currently lists six names; this becomes seven). Update the JSDoc onRESERVED_CAPABILITY_NAMESand theReservedCapabilityNametype comment.packages/core/src/messages/credentials.ts(new) — defineCredentialSchema,Credentialtype,CredentialConstraintsSchema,CredentialConstraintstype. Mirrors §9.8.1.packages/core/src/messages/execution.ts— extendJobAcceptedPayloadSchemawithcredentials?: Credential[]. ExtendJobSubscribedPayloadSchemalikewise. Import from./credentials.js.packages/core/src/messages/index.ts— re-export from./credentials.js(already usesexport *; just make the new file is picked up viaindex.tsaggregation if needed — check that the new file isn't excluded).packages/core/src/version.ts— append"model.use"and"provisioned_credentials"toV1_1_FEATURES.@arcp/runtime— provisioner interface, lifecycle wiring, revocation store, redaction.packages/runtime/src/credential-provisioner.ts(new) —CredentialProvisionerinterface,CredentialIssueContexttype,IssuedCredentialtype (server-side bookkeeping that wraps the wireCredentialplus aprovisionerIdforrevoke).packages/runtime/src/credential-store.ts(new) —CredentialStoreinterface andInMemoryCredentialStoreimplementation (parallelsIdempotencyStore/ResumeStoreinstores.ts). Persists{jobId, credentialId, provisionerId, issuedAt}so revocation survives restart in pluggable backends.packages/runtime/src/lease.ts— extendvalidateLeaseShapeto acceptmodel.use(it already accepts reserved names viaisValidCapabilityName, so the change is purely additive in the core schema). VerifyisLeaseSubsetalready handles amodel.usecapability as a plain glob-list subset (no special case needed since model identifiers are non-slash strings; the existingpatternSubsumesworks). Add explicit JSDoc notingmodel.useper §9.4.packages/runtime/src/types.ts— addcredentialProvisioner?: CredentialProvisionerandcredentialStore?: CredentialStorefields toARCPServerOptions. Addcredentials?: readonly IssuedCredential[]toJobOptions.packages/runtime/src/job.ts— addcredentials: IssuedCredential[]field onJob. InemitAccepted, includecredentials: this.credentials.map(toWireCredential)when the array is non-empty. AddapplyCredentialRotation(prev, next)andrevokeAll()helpers.packages/runtime/src/job-runner.ts— afterconstructJob, callprovisioner.issue(...)(when configured), assign the issued credentials onto theJob, then calljob.emitAccepted(). InrunHandler'sfinallyblock (afterctx.jobs.retire(...)), calljob.revokeAll()regardless of terminal state. Mirror increateDelegateJobso delegated child jobs get their own credentials.packages/runtime/src/server.ts— inmakeNegotiatedCapabilities/acceptFreshSession, only advertise"provisioned_credentials"and"model.use"whenoptions.credentialProvisioner !== undefined(i.e., filteradvertisedFeatureshere). Validate at server construction that, ifcredentialProvisioneris configured,credentialStoreis also configured (durable revocation path requirement §14).packages/runtime/src/server-subscribe.ts—buildSubscribedPayloadMUST NOT includecredentialsfor subscribers that are not the submitter; gate bydefaultJobAuthorizationPolicyplus a strict "same principal" check.buildListJobsCandidatessimilarly excludes credentials from any non-submitter view (it already excludes them by omission — confirm by reading the function; just add the field cautiously).packages/runtime/src/index.ts— re-exportCredentialProvisioner,CredentialIssueContext,IssuedCredential,CredentialStore,InMemoryCredentialStore, and the new error-translation helper.@arcp/client— surface credentials onJobHandle.packages/client/src/client-handle.ts— addcredentials?: readonly Credential[]toInvocationStateandJobHandle. Plumb throughmakeHandleFromInvocation.packages/client/src/types.ts— addcredentialsgetter toJobHandle(mirroringlease,agent,budget).packages/client/src/client.ts— on inboundjob.accepted, copyenv.payload.credentials(if present) into the invocation state.Tests — vitest, alongside the existing files.
packages/runtime/test/lease.test.ts— addmodel.useglob, subset, and rejection tests.packages/runtime/test/credential-provisioner.test.ts(new) — unit tests for in-memory provisioner lifecycle.packages/sdk/test/integration/provisioned-credentials.test.ts(new) — end-to-end tests covering all integration acceptance items.Docs — markdown.
docs/guides/leases.md— add amodel.usesection after the namespace table.docs/guides/credentials.md(new) — full guide for the credential lifecycle, with the LiteLLM mapping table.examples/provisioned-credentials/(new) —server.ts,client.ts,README.md. Use an in-memoryMockProvisionerto keep the example free of an external dependency.recipes/litellm-credentials/(new) — full LiteLLM-backed recipe withserver.ts,client.ts,README.md. Wire to/key/generateand/key/deleteover HTTP.recipes/README.md— link the new recipe.examples/README.md— link the new example.CONFORMANCE.md— claimmodel.useandprovisioned_credentialsin the §9 and §6.2 tables.Public API additions
Step-by-step changes
core: feature flags — In
packages/core/src/version.ts, append"model.use"and"provisioned_credentials"toV1_1_FEATURES. Do NOT reorder existing entries (downstream tests assert sorted equality).core: capability name — In
packages/core/src/messages/lease-schema.ts, add"model.use"toRESERVED_CAPABILITY_NAMES. Update JSDoc on the constant and theReservedCapabilityNamedoc comment.core: credential wire schema — Create
packages/core/src/messages/credentials.tswithCredentialConstraintsSchema,CredentialSchema, and the inferred types. Use the sameeffect/Schemastyle aslease-schema.ts.core: extend
JobAcceptedPayloadSchema— Inpackages/core/src/messages/execution.ts, importCredentialSchemafrom./credentials.jsand addcredentials: Schema.optional(Schema.mutable(Schema.Array(CredentialSchema)))toJobAcceptedPayloadSchema. Mirror onJobSubscribedPayloadSchema(so subscribers who pass authorization see it).core: re-exports — Ensure
packages/core/src/messages/index.tsexports the new file (it usesexport *for the directory, so creating the file may need a sibling re-export — check by running typecheck). If the index does not automatically pick upcredentials.ts, addexport * from "./credentials.js".runtime: provisioner interface — Create
packages/runtime/src/credential-provisioner.ts. DefineCredentialProvisioner,IssuedCredential,CredentialIssueContext, and atoBudgetExhausted(error, details?)helper that constructs aBudgetExhaustedError(import { BudgetExhaustedError } from "@arcp/core/errors").runtime: credential store — Create
packages/runtime/src/credential-store.ts. ImplementInMemoryCredentialStorekeyed byjobId→Map<credentialId, entry>. Provideadd,removeByJob(returns and clears the per-job list),listOutstanding.runtime: types extension — In
packages/runtime/src/types.ts, addcredentialProvisionerandcredentialStoretoARCPServerOptions. Addcredentials?: readonly IssuedCredential[]toJobOptions. Add a re-export note (no value re-export needed; types are pulled byindex.ts).runtime:
Jobclass — Inpackages/runtime/src/job.ts:credentials: IssuedCredential[] = []field.this.credentials = [...(options.credentials ?? [])].emitAccepted, includecredentials: this.credentials.map((c) => c.wire)in the payload whenthis.credentials.length > 0.public async rotateCredential(prevId: string, next: IssuedCredential): Promise<void>— replaces in-place, emits astatusevent withphase: "credential_rotated"body{ id: next.wire.id, value: next.wire.value }(DO NOT log the value).public async revokeAll(provisioner: CredentialProvisioner | undefined, store: CredentialStore | undefined, logger: Logger): Promise<void>— iteratescredentials, callsprovisioner.revoke(c.provisionerId)with one-retry-on-throw, removes entries from store. Logger entries MUST omitc.wire.value.runtime: server option validation — In
packages/runtime/src/server.tsARCPServerconstructor, ifoptions.credentialProvisioner !== undefined && options.credentialStore === undefined, thrownew InvalidRequestError("credentialProvisioner requires credentialStore for durable revocation (§14)").runtime: feature gating — In
packages/runtime/src/server.ts, modifyadvertisedFeatures(or the call site inmakeNegotiatedCapabilities) so that"provisioned_credentials"and"model.use"are filtered OUT whenoptions.credentialProvisioner === undefined. Keep them in when configured.runtime: issue on submit — In
packages/runtime/src/job-runner.tsacceptAndDispatchSubmit:constructJob(...)and beforejob.emitAccepted(), ifthis.server.options.credentialProvisioneris defined, call:issuein try/catch. On throw, emitjob.error { final_status: "error", code: "INTERNAL_ERROR" }and abort the submit beforeemitAccepted.runtime: revoke on terminal — In
runHandler'sfinallyblock, afterctx.jobs.retire(job.jobId), call:runtime: delegated jobs — In
createDelegateJob, afterconstructDelegateChildand beforechild.emitAccepted(), issue child credentials via the same path as #12, passingparentJobId: parent.jobId. The provisioner is expected to subset constraints; the runtime does not re-validate the value-bearing payload, only that the lease passed toissueis a strict subset (which is already enforced byvalidateDelegateLease).runtime: subscriber redaction — In
packages/runtime/src/server-subscribe.tsbuildSubscribedPayload, do NOT emitcredentialswhen the subscriber's principal is not the job's submitter. Inline check:const isSubmitter = ctx.state.identity?.principal === job.submitterPrincipal;and only spreadcredentialswhenisSubmitter && job.credentials.length > 0. Passctxas a new arg intobuildSubscribedPayload(already available at the caller).runtime: list-jobs scoping — In
packages/runtime/src/server-subscribe.tsbuildListJobsCandidates, the existing entry shape (JobListEntry) does not includecredentials— confirm and leave as-is (it is already redacted by omission). Add a JSDoc comment noting §14 compliance.runtime: index re-exports — In
packages/runtime/src/index.ts, add named exports forCredentialProvisioner,CredentialIssueContext,IssuedCredential,CredentialStore,InMemoryCredentialStore, andtoBudgetExhausted.client: handle plumbing — In
packages/client/src/client-handle.ts:credentials: Credential[] | undefinedtoInvocationState(initializeundefined).credentialsgetter tomakeHandleFromInvocation.In
packages/client/src/client.ts:job.acceptedroute, copyenv.payload.credentials(if present) ontoinvocation.credentials.In
packages/client/src/types.ts:readonly credentials: readonly Credential[] | undefined;toJobHandle, importingCredentialfrom@arcp/core/messages.sdk: re-exports — In
packages/sdk/src/runtime.ts(orindex.ts), re-export the new symbols (CredentialProvisioner,IssuedCredential,CredentialIssueContext,CredentialStore,InMemoryCredentialStore,toBudgetExhausted,Credential,CredentialConstraints). Match the existing pattern (see howvalidateLeaseOpis re-exported).CONFORMANCE — Add rows to the §6.2 features table (
provisioned_credentials,model.use) and a new §9.7/§9.8 subsection inCONFORMANCE.md, pointing at the new files.Docs guide — Update
docs/guides/leases.md: addmodel.useto the namespace table (gates LLM model invocation). Createdocs/guides/credentials.mdcovering issue/revoke/rotate semantics and the LiteLLM mapping table (leasecost.budget→max_budget,model.use→allowed_models,lease_constraints.expires_at→ key TTL).Example — Create
examples/provisioned-credentials/withserver.ts,client.ts,README.md. Use an in-memoryMockProvisionerthat returns deterministic{ id, value: "mock-key-${i}", endpoint: "http://localhost/api" }. Mirror the layout ofexamples/cost-budget/.Recipe — Create
recipes/litellm-credentials/withserver.ts,client.ts,README.md. ImplementLiteLLMProvisionercallingPOST /key/generate(body:{ max_budget, allowed_models, duration }) andPOST /key/delete(body:{ keys: [...] }). Usefetch(Node 22+). The recipe importsCredentialProvisionerfrom@arcp/sdk. Add link torecipes/README.md.Tests to add
packages/runtime/test/lease.test.ts— extend existing file:model.uselease passesvalidateLeaseShape.model.useglobgpt-4*matchesgpt-4o-mini;gpt-3.*rejectsclaude-3-haiku.isLeaseSubset({ "model.use": ["gpt-4*"] }, { "model.use": ["**"] })is true.isLeaseSubset({ "model.use": ["**"] }, { "model.use": ["gpt-4*"] })is false.packages/runtime/test/credential-provisioner.test.ts(new):InMemoryCredentialStore.addthenremoveByJobreturns and clears the right entries.toBudgetExhausted(new Error("upstream 402"))throwsBudgetExhaustedErrorwith code"BUDGET_EXHAUSTED",retryable: false.issueandrevokeexactly once each.packages/sdk/test/integration/provisioned-credentials.test.ts(new), usingmakePairedHarnessfrompackages/sdk/test/helpers/fixtures.ts:MockProvisionerconfigured, a submitted job receiveshandle.credentialsof length 1 with the expectedid,endpoint, and a redacted-shapevalue.job.accepted.payload.credentials[0].constraints.allowed_modelsmatches the lease'smodel.usepatterns.handle.cancel("user")) → assertprovisioner.revokewas called for that credential id.TIMEOUTviamax_runtime_sec: 0.1and a long handler → assert revoke fires.allowed_modelsis a strict subset of the parent's.client.negotiatedFeaturesdoes not include"provisioned_credentials"or"model.use".statusevent withphase: "credential_rotated"is observed on the client; the previousprovisionerIdwas passed torevoke.jobAuthorizationPolicythat allows it), thejob.subscribed.payloadMUST NOT carrycredentials.Verification commands
Run from
/Users/nficano/code/arpc/typescript-sdk:Target a single test file while iterating:
Acceptance
Lease grammar:
model.useadded toRESERVED_CAPABILITY_NAMESinpackages/core/src/messages/lease-schema.ts.validateLeaseShapeacceptsmodel.usepatterns;validateLeaseOpenforces them withPERMISSION_DENIEDon miss.isLeaseSubsetrejects childmodel.usepatterns broader than the parent withLEASE_SUBSET_VIOLATION.toBudgetExhausted(error)translates an upstream budget error toBudgetExhaustedError(packages/runtime/src/credential-provisioner.ts).Provisioned credentials:
CredentialProvisionerinterface lives inpackages/runtime/src/credential-provisioner.tswithissueandrevoke.JobRunner.acceptAndDispatchSubmitcallsissueafter lease finalization, beforejob.emitAccepted.packages/core/src/messages/credentials.tsmatches §9.8.1 (id,scheme: "bearer",value,endpoint,profile?,constraints?).cost.budget→max_spend,model.use→allowed_models,lease_constraints.expires_at→expires_at.runHandlerfinally-block callsjob.revokeAllfor all terminal states;InMemoryCredentialStoreclears the per-job entries.Job(rotateCredential) emitsstatus: credential_rotatedand revokes the priorprovisionerId.createDelegateJobissues child credentials with a subset lease; child credentials revoke with the child.Feature negotiation:
provisioned_credentialsandmodel.useare added toV1_1_FEATURESand filtered out ofadvertisedFeatureswhen no provisioner is configured.intersectFeatures) excludes the feature when either peer omits it.Security:
revokeAlllogger calls do not includewire.value(verify by code review and a regex assertion in the credential-provisioner test against the captured log output).buildSubscribedPayloadandbuildListJobsCandidatesredactcredentialsfrom non-submitter views.ARCPServerconstructor throws whencredentialProvisioneris set withoutcredentialStore.Tests:
model.useparse + subset.BUDGET_EXHAUSTEDtranslation unit test.Docs / examples:
docs/guides/leases.mddescribesmodel.use.docs/guides/credentials.mddescribes the lifecycle.examples/provisioned-credentials/with vendor-neutralMockProvisioner.recipes/litellm-credentials/wires a real LiteLLM backend.CONFORMANCE.mdclaimsmodel.useandprovisioned_credentials.