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.
This is the biggest ticket in the milestone. Build in this order: lease grammar -> provisioner interface -> wire-up in ToolInvocationHandler -> revocation -> redaction -> rotation -> delegation -> docs.
Files to touch
src/Runtime/ModelUse.php(new) — model.use pattern set + subset check.
src/Runtime/LeaseScope.php — verify; may not need changes.
src/Runtime/LeaseManager.php — extend register() / get() to carry optional ModelUse and CostBudget on the lease.
src/Messages/Permissions/LeaseGranted.php — extend wire shape with optional model.use: list<string> and cost.budget: list<string> constraints.
src/Messages/Execution/JobAccepted.php — extend with ?array $credentials = null (list of redacted-on-the-wire credential objects).
src/Messages/Execution/AgentStatus.php(new) or reuse EventEmit — for phase: credential_rotated.
src/Internal/Runtime/ToolInvocationHandler.php — issue credentials after acceptance, attach to JobAccepted, hook revocation into completeJob/failJob/cancelJob.
CONFORMANCE.md — claim model.use and provisioned_credentials once green.
Tests under tests/Unit/Runtime/Credentials/ and tests/Integration/CredentialLifecycleTest.php (new files).
Public API additions
Namespace Arcp\Runtime:
finalreadonlyclass ModelUse {
/** @param list<string> $patterns Each pattern is an allow-list glob like 'anthropic/*' or 'openai/gpt-4o'. */publicfunction__construct(publicarray$patterns);
publicstaticfunctionfromPatterns(array$patterns): self;
publicfunctionallows(string$modelId): bool;
publicfunctioncontainsSubset(self$child): bool;
}
Namespace Arcp\Runtime\Credentials:
interface CredentialProvisioner {
/** * @return list<Credential> issued credentials, scope-restricted to the lease * @throws \Arcp\Errors\ARCPException on hard failure (job should fail) */publicfunctionissue(\Arcp\Messages\Permissions\LeaseGranted$lease, \Arcp\Runtime\JobContext$ctx): array;
/** Best-effort revocation; implementations should be idempotent. */publicfunctionrevoke(string$credentialId): void;
}
finalreadonlyclass Credential {
publicfunction__construct(
publicstring$id,
publicstring$scheme, // 'bearer' minimumpublicstring$value, // secret; never loggedpublicstring$endpoint,
public ?string$profile = null,
public ?array$constraints = null, // {cost.budget?, model.use?, lease_constraints?: {expires_at}}
);
/** Wire form with `value` redacted (`***`). For cross-principal introspection. */publicfunctiontoRedactedArray(): array;
/** Wire form including `value`. Only safe for the submitting principal. */publicfunctiontoArray(): array;
}
interface CredentialStore {
publicfunctionadd(\Arcp\Ids\JobId$jobId, Credential$cred): void;
publicfunctionremove(\Arcp\Ids\JobId$jobId, string$credentialId): void;
/** @return list<Credential> */publicfunctionforJob(\Arcp\Ids\JobId$jobId): array;
/** @return list<array{job_id: string, credential_id: string}> */publicfunctionoutstanding(): array;
}
src/Runtime/ModelUse.php(new): support patterns from spec §9.7 — exact (openai/gpt-4o), prefix glob (anthropic/*), and * wildcard. Implement allows(string $modelId): bool. containsSubset(self $child): bool returns true iff every child pattern is covered by at least one parent pattern (string-prefix check after stripping trailing *).
src/Errors/ErrorCode.php: add case LeaseSubsetViolation = 'LEASE_SUBSET_VIOLATION'; with default retryable === false.
src/Messages/Permissions/LeaseGranted.php: add optional constructor params ?ModelUse $modelUse = null, ?CostBudget $costBudget = null (or carry them in extra: array<string, mixed> to preserve wire-tolerance). Update toArray()/fromArray() to serialize/deserialize model.use: list<string> and cost.budget: list<string>.
src/Runtime/LeaseManager.php: register() already stores the granted lease; verify nothing more is needed beyond carrying the extended fields on the granted message. Add ensureSubset(LeaseGranted $parent, LeaseGranted $child): void that throws LeaseSubsetViolationException when model.use or cost.budget is not a subset.
src/Runtime/Credentials/Credential.php + CredentialProvisioner.php + CredentialStore.php + InMemoryCredentialStore.php + InMemoryCredentialProvisioner.php(new): implement per signatures above. Use named-arg constructors and final readonly DTOs. Make Credential::toRedactedArray() replace value with '***' and drop nothing else.
src/Runtime/RuntimeConfig.php: add ?CredentialProvisioner $credentialProvisioner = null and ?CredentialStore $credentialStore = null constructor params. Update RuntimeConfig's withConfig flow in ARCPRuntime.
src/Runtime/ARCPRuntime.php: accept the two new deps; default credentialStore to new InMemoryCredentialStore(). Expose public readonly ?CredentialProvisioner $credentialProvisioner, public readonly CredentialStore $credentials. Wire into the dispatcher chain.
src/Messages/Execution/JobAccepted.php: extend to __construct(?string $note = null, ?array $credentials = null). toArray() emits credentials only when non-null. fromArray() reads credentials as list<array{...}> and round-trips.
src/Internal/Runtime/ToolInvocationHandler.php:
Resolve the lease referenced by arguments['lease'] (parse to LeaseId, look up via LeaseManager).
If $this->runtime->credentialProvisioner !== null AND the lease carries cost.budget or model.use, call $provisioner->issue($lease, $ctx). Wrap in try/catch; on failure emit ToolError(ErrorPayload('FAILED_PRECONDITION', ...)) and abort.
Store every returned Credential in $runtime->credentials keyed by $job->id.
Emit JobAccepted with credentials populated (full form, since it's a direct response to the submitter's tool.invoke).
Hook revocation into the three terminal paths (completeJob, failJob, cancelJob) — call $provisioner->revoke($cred->id) for every credential in $runtime->credentials->forJob($job->id), then $runtime->credentials->remove(...). Wrap each revoke in try/catch and log warnings; revocation is best-effort.
On timed_out (whenever it becomes a terminal state in JobManager/Job), do the same.
src/Runtime/JobContext.php: add rotateCredential(Credential $new, string $previousCredentialId): void that (a) revokes prior via the provisioner, (b) replaces the entry in CredentialStore, (c) emits an EventEmit (or a new AgentStatus message) with payload {phase: 'credential_rotated', id, value}.
src/Internal/Runtime/JobListHandler.phpentry(): when the requesting Session->principal !== $job->session->principal, omit credentials. When equal, include the full form. Confirm visibility rule (visible() already gates on principal equality).
src/Messages/Session/Capabilities.php: add features: list<string> constructor param + round-trip. Add withFeatures(array $features): self. In ARCPRuntime::advertisedCapabilitiesForSession(), append 'provisioned_credentials' and 'model.use' to features iff $this->credentialProvisioner !== null.
src/Internal/Runtime/HandshakeNegotiator.phpacceptSession(): compute the intersection of $open->capabilities->features and the advertised feature set; store on Session for later checks. Reject (with UNIMPLEMENTED) if the client REQUIRES provisioned_credentials (e.g. via a required_features array in extra) and we don't advertise it.
src/Runtime/ARCPRuntime.php boot guard: when credentialProvisioner !== null && !$credentialStore->supportsDurableRevocation() (add a method on CredentialStore), throw InvalidArgumentException at construction — spec §14 "Credential revocation reliability".
Delegation (§10): when AgentDelegate is implemented in the future, child credentials must be issued against the child's lease (not the parent's). For this ticket, add a TODO in Internal/Runtime/Dispatcher.php and an integration test that exercises the child-lease + child-credential path via a fake child job creation (mock the delegate path).
docs/guides/leases.md: append two sections — "Model-use leases (§9.7)" and "Provisioned credentials (§9.8)" — with worked example using InMemoryCredentialProvisioner.
samples/provisioned_credentials/main.php(new): runtime registers InMemoryCredentialProvisioner, accepts a lease with model.use: ['anthropic/*'] and cost.budget: ['USD:1.00'], invokes a tool, prints the job.accepted.credentials block client-side, then cancels and asserts revoke was called.
samples/provisioned_credentials/LiteLLMProvisioner.php(new): reference plug-in that POSTs to /key/generate with max_budget + allowed_models and DELETEs to /key/delete on revoke. Document the env-var dependencies in the README; leave HTTP client choice abstract (use Amp\Http\Client since amphp/socket is already a dep, or any PSR-18 client).
CONFORMANCE.md: add rows for model.use, provisioned_credentials, and LEASE_SUBSET_VIOLATION once tests pass.
Provisioner returns a credential; tool emits cost.search metric. Stub the upstream to return an HTTP 402 / "budget_exceeded" error; the SDK boundary translates it into a BUDGET_EXHAUSTED wire error (§9.6).
Verification commands
cd /Users/nficano/code/arpc/php-sdk
composer test -- --filter ModelUse
composer test -- --filter Credential
composer test -- --filter LeaseSubset
composer test -- --filter CredentialLifecycle
composer stan
composer psalm
composer rector
php samples/provisioned_credentials/main.php
[task] LeaseManager::ensureSubset() throws LeaseSubsetViolationException for expanded model.use or cost.budget.
[task] CredentialProvisioner + Credential + CredentialStore defined under Arcp\Runtime\Credentials\.
[task] ToolInvocationHandler issues credentials on acceptance, attaches them to JobAccepted, revokes on every terminal state (success, error, cancelled, timed_out).
[task] Revocation is best-effort with logged failures; outstanding credentials persisted in CredentialStore.
[task] Credential rotation emits a status event with phase: credential_rotated and revokes the prior value.
[task] JobListHandler::entry() redacts credentials for cross-principal introspection.
[task] Capabilities.features advertises provisioned_credentials and model.use ONLY when a provisioner is configured.
[task] Runtime construction fails fast when a provisioner is configured without a durable CredentialStore.
[task] Credential value never appears in logs, telemetry events, or subscriber broadcasts.
[task] Delegated child credentials are scoped to the child lease and revoke with the child.
[task] BUDGET_EXHAUSTED is the canonical wire error when upstream rejects a credential for budget reasons.
[task] samples/provisioned_credentials/ includes a LiteLLM-backed reference plug-in (not in core).
[task] docs/guides/leases.md and CONFORMANCE.md updated.
[task] All unit + integration tests added; composer stan && composer psalm && composer test all pass.
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
PSR-4 root:
Arcp\->src/. Tests:Arcp\Tests\->tests/. Existing pieces to lean on:Arcp\Runtime\LeaseManager,Arcp\Runtime\CostBudget,Arcp\Runtime\Job,Arcp\Runtime\JobContext,Arcp\Internal\Runtime\ToolInvocationHandler,Arcp\Messages\Execution\JobAccepted,Arcp\Messages\Session\Capabilities. Spec ref paths:spec/docs/draft-arcp-1.1.md§9.7, §9.8, §7.1, §14.This is the biggest ticket in the milestone. Build in this order: lease grammar -> provisioner interface -> wire-up in
ToolInvocationHandler-> revocation -> redaction -> rotation -> delegation -> docs.Files to touch
src/Runtime/ModelUse.php(new)—model.usepattern set + subset check.src/Runtime/LeaseScope.php— verify; may not need changes.src/Runtime/LeaseManager.php— extendregister()/get()to carry optionalModelUseandCostBudgeton the lease.src/Messages/Permissions/LeaseGranted.php— extend wire shape with optionalmodel.use: list<string>andcost.budget: list<string>constraints.src/Runtime/Credentials/CredentialProvisioner.php(new)— core interface.src/Runtime/Credentials/Credential.php(new)— readonly DTO matching wire shape.src/Runtime/Credentials/CredentialStore.php(new)— tracks outstanding credentials per job; persistable.src/Runtime/Credentials/InMemoryCredentialStore.php(new)— default impl.src/Runtime/Credentials/InMemoryCredentialProvisioner.php(new)— used by tests + reference plug-in.src/Runtime/Credentials/CredentialRotation.php(new)— optional event payload helper.src/Messages/Execution/JobAccepted.php— extend with?array $credentials = null(list of redacted-on-the-wire credential objects).src/Messages/Execution/AgentStatus.php(new)or reuseEventEmit— forphase: credential_rotated.src/Internal/Runtime/ToolInvocationHandler.php— issue credentials after acceptance, attach toJobAccepted, hook revocation intocompleteJob/failJob/cancelJob.src/Runtime/RuntimeConfig.php— add?CredentialProvisioner $credentialProvisioner = null.src/Runtime/ARCPRuntime.php— wire provisioner fromRuntimeConfig; expose aspublic readonly ?CredentialProvisioner.src/Messages/Session/Capabilities.php— addfeatures: list<string>field; advertiseprovisioned_credentialsandmodel.usewhen$runtime->credentialProvisioner !== null.src/Internal/Runtime/HandshakeNegotiator.php— compute feature intersection with the client's requested features.src/Internal/Runtime/JobListHandler.php— stripcredentialsfromentry()output when the requesting principal is not the job's submitter.src/Errors/ErrorCode.php— addLeaseSubsetViolation = 'LEASE_SUBSET_VIOLATION'.src/Errors/LeaseSubsetViolationException.php(new).src/Internal/Client/ErrorMapper.php— map the new code.docs/guides/leases.md— addmodel.useand credentials lifecycle sections.samples/provisioned_credentials/main.php(new)+samples/provisioned_credentials/README.md(new).samples/provisioned_credentials/LiteLLMProvisioner.php(new)— reference plug-in.CONFORMANCE.md— claimmodel.useandprovisioned_credentialsonce green.tests/Unit/Runtime/Credentials/andtests/Integration/CredentialLifecycleTest.php(new files).Public API additions
Namespace
Arcp\Runtime:Namespace
Arcp\Runtime\Credentials:Namespace
Arcp\Errors:Step-by-step changes
src/Runtime/ModelUse.php(new): support patterns from spec §9.7 — exact (openai/gpt-4o), prefix glob (anthropic/*), and*wildcard. Implementallows(string $modelId): bool.containsSubset(self $child): boolreturns true iff every child pattern is covered by at least one parent pattern (string-prefix check after stripping trailing*).src/Errors/ErrorCode.php: addcase LeaseSubsetViolation = 'LEASE_SUBSET_VIOLATION';with defaultretryable === false.src/Errors/LeaseSubsetViolationException.php(new):details = ['parent_lease_id' => ..., 'child_lease_id' => ..., 'field' => 'model.use'|'cost.budget'].src/Messages/Permissions/LeaseGranted.php: add optional constructor params?ModelUse $modelUse = null, ?CostBudget $costBudget = null(or carry them inextra: array<string, mixed>to preserve wire-tolerance). UpdatetoArray()/fromArray()to serialize/deserializemodel.use: list<string>andcost.budget: list<string>.src/Runtime/LeaseManager.php:register()already stores the granted lease; verify nothing more is needed beyond carrying the extended fields on the granted message. AddensureSubset(LeaseGranted $parent, LeaseGranted $child): voidthat throwsLeaseSubsetViolationExceptionwhenmodel.useorcost.budgetis not a subset.src/Runtime/Credentials/Credential.php+CredentialProvisioner.php+CredentialStore.php+InMemoryCredentialStore.php+InMemoryCredentialProvisioner.php(new): implement per signatures above. Use named-arg constructors andfinal readonlyDTOs. MakeCredential::toRedactedArray()replacevaluewith'***'and drop nothing else.src/Runtime/RuntimeConfig.php: add?CredentialProvisioner $credentialProvisioner = nulland?CredentialStore $credentialStore = nullconstructor params. UpdateRuntimeConfig'swithConfigflow inARCPRuntime.src/Runtime/ARCPRuntime.php: accept the two new deps; defaultcredentialStoretonew InMemoryCredentialStore(). Exposepublic readonly ?CredentialProvisioner $credentialProvisioner,public readonly CredentialStore $credentials. Wire into the dispatcher chain.src/Messages/Execution/JobAccepted.php: extend to__construct(?string $note = null, ?array $credentials = null).toArray()emitscredentialsonly when non-null.fromArray()readscredentialsaslist<array{...}>and round-trips.src/Internal/Runtime/ToolInvocationHandler.php:arguments['lease'](parse toLeaseId, look up viaLeaseManager).$this->runtime->credentialProvisioner !== nullAND the lease carriescost.budgetormodel.use, call$provisioner->issue($lease, $ctx). Wrap in try/catch; on failure emitToolError(ErrorPayload('FAILED_PRECONDITION', ...))and abort.Credentialin$runtime->credentialskeyed by$job->id.JobAcceptedwithcredentialspopulated (full form, since it's a direct response to the submitter'stool.invoke).completeJob,failJob,cancelJob) — call$provisioner->revoke($cred->id)for every credential in$runtime->credentials->forJob($job->id), then$runtime->credentials->remove(...). Wrap each revoke in try/catch and log warnings; revocation is best-effort.timed_out(whenever it becomes a terminal state inJobManager/Job), do the same.src/Runtime/JobContext.php: addrotateCredential(Credential $new, string $previousCredentialId): voidthat (a) revokes prior via the provisioner, (b) replaces the entry inCredentialStore, (c) emits anEventEmit(or a newAgentStatusmessage) with payload{phase: 'credential_rotated', id, value}.src/Internal/Runtime/JobListHandler.phpentry(): when the requestingSession->principal !== $job->session->principal, omitcredentials. When equal, include the full form. Confirm visibility rule (visible()already gates on principal equality).src/Messages/Session/Capabilities.php: addfeatures: list<string>constructor param + round-trip. AddwithFeatures(array $features): self. InARCPRuntime::advertisedCapabilitiesForSession(), append'provisioned_credentials'and'model.use'to features iff$this->credentialProvisioner !== null.src/Internal/Runtime/HandshakeNegotiator.phpacceptSession(): compute the intersection of$open->capabilities->featuresand the advertised feature set; store onSessionfor later checks. Reject (withUNIMPLEMENTED) if the client REQUIRESprovisioned_credentials(e.g. via arequired_featuresarray inextra) and we don't advertise it.src/Runtime/ARCPRuntime.phpboot guard: whencredentialProvisioner !== null && !$credentialStore->supportsDurableRevocation()(add a method onCredentialStore), throwInvalidArgumentExceptionat construction — spec §14 "Credential revocation reliability".Delegation (§10): when
AgentDelegateis implemented in the future, child credentials must be issued against the child's lease (not the parent's). For this ticket, add a TODO inInternal/Runtime/Dispatcher.phpand an integration test that exercises the child-lease + child-credential path via a fake child job creation (mock the delegate path).docs/guides/leases.md: append two sections — "Model-use leases (§9.7)" and "Provisioned credentials (§9.8)" — with worked example usingInMemoryCredentialProvisioner.samples/provisioned_credentials/main.php(new): runtime registersInMemoryCredentialProvisioner, accepts a lease withmodel.use: ['anthropic/*']andcost.budget: ['USD:1.00'], invokes a tool, prints thejob.accepted.credentialsblock client-side, then cancels and asserts revoke was called.samples/provisioned_credentials/LiteLLMProvisioner.php(new): reference plug-in that POSTs to/key/generatewithmax_budget+allowed_modelsand DELETEs to/key/deleteon revoke. Document the env-var dependencies in the README; leave HTTP client choice abstract (useAmp\Http\Clientsinceamphp/socketis already a dep, or any PSR-18 client).CONFORMANCE.md: add rows formodel.use,provisioned_credentials, andLEASE_SUBSET_VIOLATIONonce tests pass.Tests to add
tests/Unit/Runtime/ModelUseTest.php:testAllowsExactMatch()testAllowsPrefixGlob()(e.g.anthropic/*matchesanthropic/claude-3)testRejectsUnmatched()testContainsSubsetTrueForNarrowChild()testContainsSubsetFalseForExpandedChild()tests/Unit/Errors/LeaseSubsetViolationExceptionTest.php:testCodeAndDetails()tests/Unit/Runtime/Credentials/CredentialTest.php:testToArrayContainsValue()testToRedactedArrayMasksValue()tests/Unit/Runtime/Credentials/InMemoryCredentialStoreTest.php:testAddRemoveForJob()testOutstandingReportsAllJobs()tests/Unit/Messages/Execution/JobAcceptedTest.php:testRoundTripWithCredentials()testRoundTripWithoutCredentials()tests/Unit/Runtime/LeaseSubsetTest.php:testModelUseSubsetEnforced()(LeaseManager rejects expanded child set)testCostBudgetSubsetEnforced()tests/Integration/CredentialLifecycleTest.php:testIssueCredentialsOnJobAccepted()— assertjob.acceptedpayload carriescredentialswith fullvalue.testRevokeOnSuccess()— happy path, revoke called once.testRevokeOnFailure()— handler throws, revoke called.testRevokeOnCancel()— client cancels, revoke called.testRevokeOnTimeout()— deadline exceeded, revoke called.testRevocationIsBestEffort()— provisioner'srevokethrows; job terminal state still emitted and credentials removed from store.testCrossPrincipalListJobsRedactsCredentials()— second session/principal sees novalue(and nocredentialsfield).testCredentialRotationEmitsStatusEvent()— handler calls$ctx->rotateCredential(...); prior id revoked, new value visible.testDelegatedJobReceivesChildScopedCredential()(skip ifAgentDelegatenot yet implemented, but write the harness).testHandshakeAdvertisesFeaturesOnlyWhenProvisionerConfigured().testBoot RejectsProvisionerWithoutDurableStore().tests/Integration/BudgetExhaustedFromUpstreamTest.php:cost.searchmetric. Stub the upstream to return an HTTP 402 / "budget_exceeded" error; the SDK boundary translates it into aBUDGET_EXHAUSTEDwire error (§9.6).Verification commands
Acceptance
ModelUseparses spec §9.7 patterns and implementsallows()+containsSubset().LeaseManager::ensureSubset()throwsLeaseSubsetViolationExceptionfor expandedmodel.useorcost.budget.CredentialProvisioner+Credential+CredentialStoredefined underArcp\Runtime\Credentials\.ToolInvocationHandlerissues credentials on acceptance, attaches them toJobAccepted, revokes on every terminal state (success,error,cancelled,timed_out).CredentialStore.phase: credential_rotatedand revokes the prior value.JobListHandler::entry()redactscredentialsfor cross-principal introspection.Capabilities.featuresadvertisesprovisioned_credentialsandmodel.useONLY when a provisioner is configured.CredentialStore.valuenever appears in logs, telemetry events, or subscriber broadcasts.BUDGET_EXHAUSTEDis the canonical wire error when upstream rejects a credential for budget reasons.samples/provisioned_credentials/includes a LiteLLM-backed reference plug-in (not in core).docs/guides/leases.mdandCONFORMANCE.mdupdated.composer stan && composer psalm && composer testall pass.