ARCP v1.1 §9.6 introduces cost.budget capability with runtime-enforced counters. Not implemented.
Steps:
- Add
CostBudget lease capability with max, unit, consumed fields (use bcmath or Brick\Math for precision)
- Runtime increments counter on cost-tagged metric events
- On exceeding budget, emit
BUDGET_EXHAUSTED error
- Implement subset enforcement (child ≤ parent)
- Add
samples/cost_budget/ demo
- Tests
Reference: spec §9.6.
Audit reference: ARCP SDK Audit v1.1 (2026-05-19)
Implementation prompt
Largely landed; "verify + harden + extend" pass. PSR-4 root: Arcp\ -> src/. Tests: Arcp\Tests\ -> tests/. Spec ref: spec/docs/draft-arcp-1.1.md §9.6, §12 errors. Precision is handled via an internal int scaled by 1_000_000 (see CostBudget::SCALE); do not add bcmath/Brick\Math.
Files to touch
src/Runtime/CostBudget.php — existing. Verify and audit edge cases.
src/Errors/BudgetExhaustedException.php — existing. Verify only.
src/Errors/ErrorCode.php — confirm BudgetExhausted = 'BUDGET_EXHAUSTED' case.
src/Runtime/Job.php — confirm ?CostBudget $budget is a constructor arg.
src/Runtime/JobManager.php start() — confirm ?CostBudget $budget = null parameter is forwarded to Job.
src/Internal/Runtime/ToolInvocationHandler.php — confirm CostBudget::fromInvocationArguments($msg->arguments) is called and the result is passed to $this->runtime->jobs->start(). Also confirm that BudgetExhaustedException thrown from inside the tool fiber is caught and emitted as a tool.error (via ErrorPayload::fromException) with details: {currency, remaining}.
src/Runtime/JobContext.php emitMetric() — confirm the cost-tagged metric path calls $job->budget?->consume($name, $value, $unit) and propagates BudgetExhaustedException out of the fiber so the tool handler unwinds.
src/Internal/Client/ErrorMapper.php — confirm BudgetExhausted is mapped to BudgetExhaustedException with details.currency and details.remaining.
samples/cost_budget/main.php — existing. Verify runnable.
tests/Unit/Runtime/V11FeaturesTest.php — already covers consume/exhaust/subset. Extend with edge cases below.
tests/Unit/Runtime/CostBudgetTest.php (new) — focused unit.
tests/Integration/CostBudgetTest.php (new) — integration.
Public API additions
Namespace Arcp\Runtime:
final class CostBudget {
public static function fromPatterns(array $patterns): self; // ['USD:1.00', 'tokens:1000']
public static function fromInvocationArguments(array $arguments): ?self; // reads `cost.budget` or `lease.cost.budget`
public function consume(string $metricName, int|float $value, string $unit): ?string; // returns formatted remaining; throws BudgetExhaustedException
/** @return array<string, string> */
public function remaining(): array;
public function containsSubset(self $child): bool;
}
Namespace Arcp\Errors:
final class BudgetExhaustedException extends ARCPException {
public function __construct(
string $currency,
int|float|string $remaining,
string $message = '',
?\Throwable $previous = null,
);
public function code(): ErrorCode; // ErrorCode::BudgetExhausted
}
Step-by-step changes
src/Runtime/CostBudget.php:
- Confirm
parse() accepts pattern currency:amount where amount is \d+(\.\d+)? and currency matches [A-Za-z][A-Za-z0-9_-]*.
- Confirm
consume() only acts on metric names starting with cost. (other than cost.budget.remaining). Audit: the current check $this->remaining[$unit] <= 0 throws AFTER subtracting; ensure the wire remaining reported in the exception matches the spec (negative-clipped to 0 is acceptable but document).
- Confirm
containsSubset() compares scaled ints, currency by currency.
src/Runtime/JobContext.php emitMetric(): confirm budget consumption happens BEFORE the MetricEvent is emitted, so an exhausted budget aborts the tool before the metric escapes. The current implementation injects dims['budget_remaining'] — keep that.
src/Internal/Runtime/ToolInvocationHandler.php: confirm BudgetExhaustedException (an ARCPException) is caught by the existing catch (ARCPException $e) arm and $this->failJob(..., ErrorPayload::fromException($e)) produces a wire error with code: 'BUDGET_EXHAUSTED' and details: {currency, remaining}.
src/Errors/ErrorPayload.php (read to confirm): make sure ErrorPayload::fromException($e) reads $e->code()->value and $e->details() so the wire payload has correct shape.
src/Errors/BudgetExhaustedException.php: confirm details is ['currency' => ..., 'remaining' => ...] and retryable === false.
src/Internal/Client/ErrorMapper.php: confirm budgetExhausted() reconstructs the exception with currency and remaining from $err->details.
samples/cost_budget/main.php: open, confirm it (a) attaches arguments: ['cost.budget' => ['USD:0.10']] on invokeTool, (b) emits metrics until exhaustion, (c) shows the client catching BudgetExhaustedException. Run with php samples/cost_budget/main.php.
tests/Unit/Runtime/CostBudgetTest.php (new): add cases not in V11FeaturesTest.php:
testConsumeIgnoresNonCostMetrics() (e.g. latency.ms).
testConsumeIgnoresCostBudgetRemaining() (the sentinel name).
testMultipleCurrenciesIndependent() — USD:1.00, tokens:1000; consume tokens does not touch USD.
testFractionalPrecision() — 0.000001 increments and never underflows below zero by 1.
testParseRejectsBadPattern() — 'USD' or 'USD:abc' throws InvalidArgumentException.
testSubsetMixedCurrencies() — parent has USD only; child requests tokens -> containsSubset false.
tests/Integration/CostBudgetTest.php (new):
- Register a tool whose handler calls
$ctx->emitMetric('cost.search', 0.05, 'USD') in a loop. Pass arguments: ['cost.budget' => ['USD:0.10']] to invokeTool. Assert third emit causes the job to fail with wire BUDGET_EXHAUSTED; client receives typed BudgetExhaustedException with details.currency === 'USD'.
- Add a test that emitted
MetricEvents before exhaustion include dims.budget_remaining populated.
docs/guides/leases.md (already exists): append a short subsection "Cost budgets (v1.1 §9.6)" describing the pattern format and the tool.invoke arguments['cost.budget'] shape.
Tests to add
tests/Unit/Runtime/CostBudgetTest.php (cases above).
tests/Integration/CostBudgetTest.php:
testJobFailsWithBudgetExhausted()
testClientReceivesTypedBudgetExhaustedException()
testMetricEventsCarryBudgetRemainingDim()
Verification commands
cd /Users/nficano/code/arpc/php-sdk
composer test -- --filter CostBudget
composer test -- --filter V11Features
composer stan
composer psalm
php samples/cost_budget/main.php
Acceptance
- [task]
CostBudget parses currency:amount patterns from tool.invoke arguments['cost.budget'] and from arguments['lease']['cost.budget'].
- [task]
consume() ignores non-cost.* metrics and the cost.budget.remaining sentinel.
- [task]
BudgetExhaustedException thrown when a currency counter reaches zero or below.
- [task]
JobContext::emitMetric() injects budget_remaining into the metric event's dims.
- [task] Wire error has
code: 'BUDGET_EXHAUSTED', details: {currency, remaining}, retryable: false.
- [task]
ErrorMapper reconstructs typed BudgetExhaustedException on the client side.
- [task]
containsSubset() enforces child ≤ parent per currency; child with an unknown currency is rejected.
- [task]
samples/cost_budget/main.php runs and demonstrates exhaustion.
- [task] Unit + integration tests added;
docs/guides/leases.md updated.
- [task]
composer stan && composer psalm && composer test all pass.
ARCP v1.1 §9.6 introduces
cost.budgetcapability with runtime-enforced counters. Not implemented.Steps:
CostBudgetlease capability withmax,unit,consumedfields (usebcmathorBrick\Mathfor precision)BUDGET_EXHAUSTEDerrorsamples/cost_budget/demoReference: spec §9.6.
Audit reference: ARCP SDK Audit v1.1 (2026-05-19)
Implementation prompt
Largely landed; "verify + harden + extend" pass. PSR-4 root:
Arcp\->src/. Tests:Arcp\Tests\->tests/. Spec ref:spec/docs/draft-arcp-1.1.md§9.6, §12 errors. Precision is handled via an internalintscaled by1_000_000(seeCostBudget::SCALE); do not addbcmath/Brick\Math.Files to touch
src/Runtime/CostBudget.php— existing. Verify and audit edge cases.src/Errors/BudgetExhaustedException.php— existing. Verify only.src/Errors/ErrorCode.php— confirmBudgetExhausted = 'BUDGET_EXHAUSTED'case.src/Runtime/Job.php— confirm?CostBudget $budgetis a constructor arg.src/Runtime/JobManager.phpstart()— confirm?CostBudget $budget = nullparameter is forwarded toJob.src/Internal/Runtime/ToolInvocationHandler.php— confirmCostBudget::fromInvocationArguments($msg->arguments)is called and the result is passed to$this->runtime->jobs->start(). Also confirm thatBudgetExhaustedExceptionthrown from inside the tool fiber is caught and emitted as atool.error(viaErrorPayload::fromException) withdetails: {currency, remaining}.src/Runtime/JobContext.phpemitMetric()— confirm the cost-tagged metric path calls$job->budget?->consume($name, $value, $unit)and propagatesBudgetExhaustedExceptionout of the fiber so the tool handler unwinds.src/Internal/Client/ErrorMapper.php— confirmBudgetExhaustedis mapped toBudgetExhaustedExceptionwithdetails.currencyanddetails.remaining.samples/cost_budget/main.php— existing. Verify runnable.tests/Unit/Runtime/V11FeaturesTest.php— already covers consume/exhaust/subset. Extend with edge cases below.tests/Unit/Runtime/CostBudgetTest.php(new)— focused unit.tests/Integration/CostBudgetTest.php(new)— integration.Public API additions
Namespace
Arcp\Runtime:Namespace
Arcp\Errors:Step-by-step changes
src/Runtime/CostBudget.php:parse()accepts patterncurrency:amountwhere amount is\d+(\.\d+)?and currency matches[A-Za-z][A-Za-z0-9_-]*.consume()only acts on metric names starting withcost.(other thancost.budget.remaining). Audit: the current check$this->remaining[$unit] <= 0throws AFTER subtracting; ensure the wireremainingreported in the exception matches the spec (negative-clipped to 0 is acceptable but document).containsSubset()compares scaled ints, currency by currency.src/Runtime/JobContext.phpemitMetric(): confirm budget consumption happens BEFORE theMetricEventis emitted, so an exhausted budget aborts the tool before the metric escapes. The current implementation injectsdims['budget_remaining']— keep that.src/Internal/Runtime/ToolInvocationHandler.php: confirmBudgetExhaustedException(anARCPException) is caught by the existingcatch (ARCPException $e)arm and$this->failJob(..., ErrorPayload::fromException($e))produces a wire error withcode: 'BUDGET_EXHAUSTED'anddetails: {currency, remaining}.src/Errors/ErrorPayload.php(read to confirm): make sureErrorPayload::fromException($e)reads$e->code()->valueand$e->details()so the wire payload has correct shape.src/Errors/BudgetExhaustedException.php: confirmdetailsis['currency' => ..., 'remaining' => ...]andretryable === false.src/Internal/Client/ErrorMapper.php: confirmbudgetExhausted()reconstructs the exception withcurrencyandremainingfrom$err->details.samples/cost_budget/main.php: open, confirm it (a) attachesarguments: ['cost.budget' => ['USD:0.10']]oninvokeTool, (b) emits metrics until exhaustion, (c) shows the client catchingBudgetExhaustedException. Run withphp samples/cost_budget/main.php.tests/Unit/Runtime/CostBudgetTest.php(new): add cases not inV11FeaturesTest.php:testConsumeIgnoresNonCostMetrics()(e.g.latency.ms).testConsumeIgnoresCostBudgetRemaining()(the sentinel name).testMultipleCurrenciesIndependent()—USD:1.00, tokens:1000; consume tokens does not touch USD.testFractionalPrecision()— 0.000001 increments and never underflows below zero by 1.testParseRejectsBadPattern()—'USD'or'USD:abc'throwsInvalidArgumentException.testSubsetMixedCurrencies()— parent has USD only; child requests tokens ->containsSubsetfalse.tests/Integration/CostBudgetTest.php(new):$ctx->emitMetric('cost.search', 0.05, 'USD')in a loop. Passarguments: ['cost.budget' => ['USD:0.10']]toinvokeTool. Assert third emit causes the job to fail with wireBUDGET_EXHAUSTED; client receives typedBudgetExhaustedExceptionwithdetails.currency === 'USD'.MetricEvents before exhaustion includedims.budget_remainingpopulated.docs/guides/leases.md(already exists): append a short subsection "Cost budgets (v1.1 §9.6)" describing the pattern format and thetool.invoke arguments['cost.budget']shape.Tests to add
tests/Unit/Runtime/CostBudgetTest.php(cases above).tests/Integration/CostBudgetTest.php:testJobFailsWithBudgetExhausted()testClientReceivesTypedBudgetExhaustedException()testMetricEventsCarryBudgetRemainingDim()Verification commands
Acceptance
CostBudgetparsescurrency:amountpatterns fromtool.invoke arguments['cost.budget']and fromarguments['lease']['cost.budget'].consume()ignores non-cost.*metrics and thecost.budget.remainingsentinel.BudgetExhaustedExceptionthrown when a currency counter reaches zero or below.JobContext::emitMetric()injectsbudget_remaininginto the metric event's dims.code: 'BUDGET_EXHAUSTED',details: {currency, remaining},retryable: false.ErrorMapperreconstructs typedBudgetExhaustedExceptionon the client side.containsSubset()enforces child ≤ parent per currency; child with an unknown currency is rejected.samples/cost_budget/main.phpruns and demonstrates exhaustion.docs/guides/leases.mdupdated.composer stan && composer psalm && composer testall pass.