Skip to content

Implement lease-bound provisioned credentials (spec §9.7, §9.8) #28

@nficano

Description

@nficano

Goal

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.

Tests

  • Unit: lease parser accepts model.use patterns; subsetting rejects expanded model sets.
  • 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.

References

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.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/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 reuse EventEmit — for phase: credential_rotated.
  • src/Internal/Runtime/ToolInvocationHandler.php — issue credentials after acceptance, attach to JobAccepted, hook revocation into completeJob/failJob/cancelJob.
  • src/Runtime/RuntimeConfig.php — add ?CredentialProvisioner $credentialProvisioner = null.
  • src/Runtime/ARCPRuntime.php — wire provisioner from RuntimeConfig; expose as public readonly ?CredentialProvisioner.
  • src/Messages/Session/Capabilities.php — add features: list<string> field; advertise provisioned_credentials and model.use when $runtime->credentialProvisioner !== null.
  • src/Internal/Runtime/HandshakeNegotiator.php — compute feature intersection with the client's requested features.
  • src/Internal/Runtime/JobListHandler.php — strip credentials from entry() output when the requesting principal is not the job's submitter.
  • src/Errors/ErrorCode.php — add LeaseSubsetViolation = 'LEASE_SUBSET_VIOLATION'.
  • src/Errors/LeaseSubsetViolationException.php (new).
  • src/Internal/Client/ErrorMapper.php — map the new code.
  • docs/guides/leases.md — add model.use and 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 — 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:

final readonly class ModelUse {
    /** @param list<string> $patterns Each pattern is an allow-list glob like 'anthropic/*' or 'openai/gpt-4o'. */
    public function __construct(public array $patterns);
    public static function fromPatterns(array $patterns): self;
    public function allows(string $modelId): bool;
    public function containsSubset(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)
     */
    public function issue(\Arcp\Messages\Permissions\LeaseGranted $lease, \Arcp\Runtime\JobContext $ctx): array;

    /** Best-effort revocation; implementations should be idempotent. */
    public function revoke(string $credentialId): void;
}

final readonly class Credential {
    public function __construct(
        public string $id,
        public string $scheme,                       // 'bearer' minimum
        public string $value,                        // secret; never logged
        public string $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. */
    public function toRedactedArray(): array;
    /** Wire form including `value`. Only safe for the submitting principal. */
    public function toArray(): array;
}

interface CredentialStore {
    public function add(\Arcp\Ids\JobId $jobId, Credential $cred): void;
    public function remove(\Arcp\Ids\JobId $jobId, string $credentialId): void;
    /** @return list<Credential> */
    public function forJob(\Arcp\Ids\JobId $jobId): array;
    /** @return list<array{job_id: string, credential_id: string}> */
    public function outstanding(): array;
}

Namespace Arcp\Errors:

final class LeaseSubsetViolationException extends ARCPException {
    public function __construct(string $parentLeaseId, string $childLeaseId, string $field, string $message = '');
    public function code(): ErrorCode;   // ErrorCode::LeaseSubsetViolation
}

Step-by-step changes

  1. 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 *).

  2. src/Errors/ErrorCode.php: add case LeaseSubsetViolation = 'LEASE_SUBSET_VIOLATION'; with default retryable === false.

  3. src/Errors/LeaseSubsetViolationException.php (new): details = ['parent_lease_id' => ..., 'child_lease_id' => ..., 'field' => 'model.use'|'cost.budget'].

  4. 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>.

  5. 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.

  6. 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.

  7. src/Runtime/RuntimeConfig.php: add ?CredentialProvisioner $credentialProvisioner = null and ?CredentialStore $credentialStore = null constructor params. Update RuntimeConfig's withConfig flow in ARCPRuntime.

  8. 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.

  9. 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.

  10. 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.
  11. 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}.

  12. src/Internal/Runtime/JobListHandler.php entry(): 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).

  13. 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.

  14. src/Internal/Runtime/HandshakeNegotiator.php acceptSession(): 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.

  15. 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".

  16. 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).

  17. docs/guides/leases.md: append two sections — "Model-use leases (§9.7)" and "Provisioned credentials (§9.8)" — with worked example using InMemoryCredentialProvisioner.

  18. 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.

  19. 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).

  20. CONFORMANCE.md: add rows for model.use, provisioned_credentials, and LEASE_SUBSET_VIOLATION once tests pass.

Tests to add

  • tests/Unit/Runtime/ModelUseTest.php:
    • testAllowsExactMatch()
    • testAllowsPrefixGlob() (e.g. anthropic/* matches anthropic/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() — assert job.accepted payload carries credentials with full value.
    • testRevokeOnSuccess() — happy path, revoke called once.
    • testRevokeOnFailure() — handler throws, revoke called.
    • testRevokeOnCancel() — client cancels, revoke called.
    • testRevokeOnTimeout() — deadline exceeded, revoke called.
    • testRevocationIsBestEffort() — provisioner's revoke throws; job terminal state still emitted and credentials removed from store.
    • testCrossPrincipalListJobsRedactsCredentials() — second session/principal sees no value (and no credentials field).
    • testCredentialRotationEmitsStatusEvent() — handler calls $ctx->rotateCredential(...); prior id revoked, new value visible.
    • testDelegatedJobReceivesChildScopedCredential() (skip if AgentDelegate not yet implemented, but write the harness).
    • testHandshakeAdvertisesFeaturesOnlyWhenProvisionerConfigured().
    • testBoot RejectsProvisionerWithoutDurableStore().
  • tests/Integration/BudgetExhaustedFromUpstreamTest.php:
    • 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

Acceptance

  • [task] ModelUse parses spec §9.7 patterns and implements allows() + containsSubset().
  • [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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature implementationv1.1ARCP v1.1 feature work

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions