fissible/verdict

Policy-bound actions and security evidence for Laravel AI agents.

Maintainers

Package info

github.com/fissible/verdict

pkg:composer/fissible/verdict

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 4

v0.1.1 2026-08-02 02:38 UTC

This package is auto-updated.

Last update: 2026-08-05 06:29:21 UTC


README

Policy-bound actions, security evidence, and adversarial evaluation for Laravel AI agents.

Project status: pre-1.0 developer preview. Runtime authorization, verified confirmation, semantic execution limits, strict at-most-once executor admission, and deterministic context-release slices exist on main, together with an explicit redacted provenance ledger, an opt-in database evidence recorder, a deterministic security-evaluation foundation, and a storefront security workbench. v0.1.0 is the first tagged developer preview; the public API is not stable yet. Sections labeled planned, proposed, or illustrative describe direction rather than shipped behavior.

Verdict is an early Laravel package for applications that allow AI agents to read sensitive context, call tools, or change application state. Its central rule is simple:

The model may propose an action. The application must authorize it.

Verdict is intended to complement laravel/ai, not replace it. Laravel AI owns agents, providers, prompts, tools, structured output, streaming, conversations, and provider integration. Verdict aims to add a security boundary around the data and authority those agents receive.

The first target is Laravel. A framework-independent extraction may make sense later, but the project will begin by solving this problem well in a Laravel application.

What Verdict is trying to answer

For any agent run:

  1. What was this agent allowed to do?
  2. What untrusted content entered its context?
  3. Did its proposed or observed behavior violate policy?
  4. Could known attacks manipulate it?
  5. Did a model, prompt, tool, policy, or document change reduce security?
  6. What evidence supports the result?
  7. What data left the application, and to which destination?
  8. Was a duplicate logical operation prevented from entering the executor?

Verdict aims to be holistic at this boundary. It is not intended to be a complete AI security, privacy, identity, compliance, or observability platform.

Why this needs an application boundary

An LLM is useful precisely because it can interpret ambiguous requests, combine context, choose tools, and adapt. Those strengths do not make its output suitable as an authorization decision.

Consider a storefront assistant:

Customer: Cancel the bag I ordered yesterday, unless it has already shipped.

The model can help determine that the customer is asking about cancellation and identify the likely order. The application must still determine:

  • Who is the authenticated customer?
  • Which canonical order does "the bag I ordered yesterday" refer to?
  • Does that customer own the order?
  • Has its state changed since the proposal was created?
  • Is it still eligible for cancellation?
  • Is confirmation required?
  • Has this exact operation already executed?

Prompt instructions cannot safely answer those questions. Laravel code can.

Proposed lifecycle

Application context
    -> release only permitted data to the selected agent/provider
    -> model interprets the request and proposes a typed capability
    -> application resolves canonical resources
    -> Verdict binds principal, tenant, resources, and normalized arguments
    -> Laravel Policy and other deterministic rules decide
    -> permit, deny, throttle, request confirmation, or request review
    -> re-authorize immediately before execution
    -> atomically claim the logical operation when configured
    -> execute deterministic application code
    -> record redacted evidence and emit security signals

The proposed core vocabulary is:

  • Capability — a developer-defined application operation an agent may propose.
  • Proposal — the model's untrusted interpretation of user intent.
  • Envelope — a proposal bound to server-resolved identity, resources, arguments, policy, expiry, and replay protection.
  • Decision — permit, deny, require confirmation, require review, or throttle.
  • Execution — deterministic application code that performs the operation.
  • Evidence — observable facts about what was released, proposed, decided, and executed.

Capabilities

A capability should represent a domain operation, not unrestricted CRUD access.

Prefer:

orders.view
orders.cancel
returns.start
cart.add
support.escalate

Avoid giving a model broad primitives such as:

database.update
orders.set_status
refunds.set_amount
customers.find_by_id

The implemented foundation is intentionally smaller than the eventual fluent API. A capability currently names a Laravel ability and resolves the canonical policy target from an untrusted proposal:

use Fissible\Verdict\Actions\ActionContext;
use Fissible\Verdict\Actions\ActionEnvelope;
use Fissible\Verdict\Actions\AuthorizedAction;
use Fissible\Verdict\Capabilities\Capability;
use Fissible\Verdict\Exceptions\TargetNotResolvable;
use Fissible\Verdict\Facades\Verdict;
use Fissible\Verdict\Targets\ExecutionTargetPolicy;
use Laravel\Ai\Tools\Request;

$orderTargetPolicy = ExecutionTargetPolicy::refresh(
    name: 'order-primary-key',
    identityUsing: fn (ActionEnvelope $envelope, Order $order): array => [
        'tenant_id' => $order->tenant_id,
        'resource_type' => 'order',
        'resource_id' => $order->getKey(),
    ],
    refreshUsing: fn (ActionEnvelope $envelope, Order $proposalTarget): Order => Order::query()
        ->where('tenant_id', $proposalTarget->tenant_id)
        ->find($proposalTarget->getKey())
        ?? throw TargetNotResolvable::make(),
);

Verdict::capability(Capability::usingPolicy(
    name: 'orders.view',
    ability: 'view',
    resolveTarget: fn (ActionEnvelope $envelope): Order => Order::find(
        $envelope->proposal->arguments['order_id'],
    ) ?? throw TargetNotResolvable::make(),
)->executionTarget($orderTargetPolicy)->executeUsing(function (AuthorizedAction $action): string {
    if (! $action->target instanceof Order) {
        throw new LogicException('Expected a bound order.');
    }

    return $action->target->toJson();
}));

final class StorefrontAgent implements HasTools
{
    public function tools(): iterable
    {
        return [
            Verdict::bound(
                definition: new LookupOrder,
                capability: 'orders.view',
                context: fn (Request $request): ActionContext => new ActionContext(auth()->user()),
            ),
        ];
    }
}

BoundTool delegates the existing Laravel AI tool name, description, schema, and approval requirement, but never calls that tool's handle() method. At invocation time it:

  1. Binds the server-provided actor and untrusted proposal into an envelope.
  2. Resolves a proposal-stage target and asks Laravel's Gate to inspect it.
  3. Requires the capability to select an explicit execution-target policy.
  4. Fingerprints stable resource identity before refreshing or explicitly accepting the snapshot.
  5. Denies resource substitution and re-inspects the execution target with Laravel's Gate.
  6. Uses that same execution target for approval, rate, claim, and deterministic executor bindings.

Missing capabilities, expected target-resolution failures, missing executors, and either denied authorization produce a recorded denial. Capability resolvers signal an expected missing or stale target by throwing TargetNotResolvable; unexpected resolver and authorizer exceptions remain application faults and are not mislabeled as policy decisions. Neither resolver nor authorizer faults execute the action. The definition tool exists only to provide Laravel AI metadata; its raw handler is not an execution fallback.

Warning

GuardedTool and Verdict::guard(...) help migrate your application's existing, pre-Verdict Laravel AI tools onto Verdict's authorization boundary without rewriting them. Do not use them for new security-sensitive capabilities. They authorize a resolved target and then delegate to an independent handler, so Verdict cannot establish that the handler acts on the same target. Use BoundTool for new capabilities.

ExecutionTargetPolicy::refresh(...) derives the second read from trusted context and the resolved proposal target, not by reparsing model-controlled identifiers. Stable identity must match before execution authorization. Immutable or request-local targets may instead select the deliberately cautionary acceptStaleSnapshot(...); silent snapshot reuse fails closed. Target-refresh evidence stores opaque identity fingerprints rather than raw identifiers.

Fresh resolution narrows but does not eliminate the time-of-check/time-of-use window between the second read, authorization, and execution. ADR 0003 defines this boundary and deliberately leaves transactions and locking for a separate design. Mutating executors remain responsible for appropriate transaction, locking, outbox, and downstream idempotency practices.

The model-provided tool-call ID is captured as transport metadata in evidence, not trusted as the identity of a logical operation. The confirmation slice described below enforces expiring, single-use approval receipts for BoundTool. Capabilities may also opt into strict at-most-once executor admission and semantic execution limits as described below. Verdict does not yet enforce review or exactly-once external side effects.

Verified confirmations

BoundTool capabilities may require confirmation, but they must explicitly define the trusted identity and state to which an approval is bound:

Verdict::capability(
    Capability::usingPolicy(
        name: 'orders.cancel',
        ability: 'cancel',
        resolveTarget: fn (ActionEnvelope $envelope) => Order::find(
            $envelope->proposal->arguments['order_id'],
        ) ?? throw TargetNotResolvable::make(),
    )->executionTarget($orderTargetPolicy)->requiresConfirmation(
        bindUsing: fn (ActionEnvelope $envelope, Order $order): array => [
            'actor_id' => $envelope->context->actor->getAuthIdentifier(),
            'tenant_id' => $order->tenant_id,
            'order_id' => $order->getKey(),
            'order_version' => $order->updated_at?->getTimestamp(),
        ],
        reason: 'Confirm cancellation of this order.',
        ttlSeconds: 300,
    )->executeUsing(fn (AuthorizedAction $action) => CancelOrder::run($action->target)),
);

Verdict always includes the capability name, execution-target policy name, and complete proposed arguments in the receipt fingerprint. The binding adds identity and canonical resource state that Verdict cannot infer for an arbitrary target. Include the principal, tenant, resource identity, and any state change that must invalidate approval. Raw binding values are hashed rather than stored in the receipt.

Add VerdictApprovalMiddleware to an agent that resumes protected approvals:

public function middleware(): array
{
    return [app(VerdictApprovalMiddleware::class)];
}

When Laravel AI returns a PendingApproval, resolve its Verdict challenge inside an endpoint that has already authorized access to the conversation and pending call:

use Laravel\Ai\Approvals\Decision as LaravelApprovalDecision;
use Laravel\Ai\Approvals\Decisions;

$challenge = Verdict::approvals()->challengeForToolCall($pendingApproval->id);

abort_if($challenge === null, 409);

$transition = Verdict::approvals()->approve(
    receiptId: $challenge->receiptId,
    toolCallId: $challenge->toolCallId,
    approvedBy: 'customer:'.auth()->id(),
);

abort_unless($transition->succeeded(), 409);

$response = $agent->prompt(Decisions::from([
    $pendingApproval->id => LaravelApprovalDecision::approve(),
]));

Use an opaque application identifier such as customer:72 for approvedBy, not an email address or other unnecessary PII. Verdict does not authenticate this string; the application endpoint must authenticate the decision maker and authorize access to the conversation and pending action.

The database store advances a receipt from pending to approved to consumed under a transaction and row lock. Execution requires all of the following: the unpredictable receipt ID was approved, the receipt is unexpired, Laravel is resuming with an explicit approval for that tool-call ID, the capability and full arguments still match, and the application-defined binding still matches. Direct handle() calls, replay, changed arguments, expired receipts, edited approvals, and wildcard approvals fail closed.

Verdict does not assume provider tool-call IDs are globally unique. The database uniqueness boundary also includes the capability and exact binding fingerprint. A tool-call-only challenge lookup returns no result when multiple retained receipts make it ambiguous.

This is an early synchronous Laravel AI integration. Streaming approval resumption is not yet supported because agent middleware returns before a stream is consumed; protected execution will fail closed without the scoped approval context. GuardedTool does not support verified confirmation because its independent handler cannot be bound to the authorized target.

The longer-term fluent API remains an illustrative design:

Verdict::capability('orders.cancel')
    ->arguments(CancelOrderData::class)
    ->resolveUsing(ResolveCustomerOrder::class)
    ->authorizeUsing(OrderPolicy::class, 'cancel')
    ->requiresConfirmation()
    ->rateLimits(CancelOrderLimits::class)
    ->idempotent()
    ->executeUsing(CancelOrder::class);

That full API has not been selected. The intended separation has:

  • The model selects a bounded capability and proposes semantic arguments.
  • Server-side code resolves references such as "my latest order."
  • Laravel authorization decides whether the principal may act on the resolved resource.
  • A deterministic handler performs the side effect.

The model should not choose its own principal, tenant, credential, authorization scope, approval token, or idempotency key.

Laravel Policies are still the source of truth

Verdict does not replace Laravel Gates, Policies, or properly scoped Eloquent queries.

A correctly applied Policy or customer-scoped query can fully prevent an insecure direct object reference. The problem is that defining a Policy does not automatically invoke it when an AI tool handler runs.

This raw tool is vulnerable even if an OrderPolicy exists elsewhere:

final class LookupOrder implements Tool
{
    public function handle(Request $request): string
    {
        return Order::findOrFail($request['order_id'])->toJson();
    }
}

A developer can fix that individual tool with ordinary Laravel:

$order = $customer->orders()->findOrFail($request['order_id']);

Gate::forUser($customer)->authorize('view', $order);

Verdict's intended value is not a more powerful ownership check. It is making the existing check an explicit and auditable phase for every protected AI capability, with the correct principal and canonical resource bound before execution.

Laravel decides whether the user may view the order. Verdict aims to ensure that an AI-proposed order lookup cannot silently bypass that decision.

Verdict enforcement only protects execution paths that actually pass through GuardedTool or VerdictManager. An unwrapped tool remains an ordinary Laravel AI tool and can bypass Verdict. An audit command is planned to identify unguarded application tools and other known bypasses.

Optional planner agents

Some capabilities may benefit from a specialized planner agent. Others should not pay for a second model call.

Verdict::capability('returns.start')
    ->planUsing(ReturnPlannerAgent::class)
    ->authorizeUsing(ReturnPolicy::class, 'start')
    ->executeUsing(StartReturn::class);

The planner could be an ordinary Laravel AI agent configured for OpenAI, Anthropic, Ollama, or another supported provider. The agent would produce a typed proposal; it would not receive more authority merely because it uses a different model.

The name planUsing is intentional. The executor or handler should remain deterministic PHP code.

Data release and PII scrubbing

Sensitive data may leave the application before any tool is called. Verdict's first context-release slice treats structured model context as an explicit, fail-closed policy decision.

Origin, trust, and sensitivity are separate properties. A customer record can be trusted application data and still contain PII that should not be sent to a provider.

Register an exact route from a labeled source to a resolved connection and trust zone:

Verdict::releasePolicy(
    ReleasePolicy::between(
        Source::application('customer-profile'),
        Destination::connection('ollama-local', 'local-machine'),
    )
        ->allow(DataClass::PII)
        ->whenTrustIs(Trust::Trusted),
);

Then release only explicitly projected fields:

$result = Verdict::release(CustomerContext::from($customer))
    ->source(Source::application('customer-profile'))
    ->trust(Trust::Trusted)
    ->classify(DataClass::PII)
    ->only([
        'first_name',
        'locale',
        'email',
        'orders.*.number',
        'orders.*.status',
    ])
    ->redact(['email'])
    ->to(Destination::connection('ollama-local', 'local-machine'));

if (! $result->permitted) {
    // Nothing was released.
}

$providerContext = $result->payload;

This slice prepares an authorized payload; it does not yet intercept Laravel AI prompts or send data to a provider. The application must pass only $result->payload into the selected agent or provider. An adapter that mediates Laravel AI context automatically remains planned.

DataClass::PII is an application-supplied classification, not a detection result. Verdict does not inspect the payload and infer that classification in this slice.

Field allowlists should be preferred over exclusions such as except: ['ssn', 'dob']. An exclusion can begin leaking a newly added tax_id or medical_notes field without any policy change.

The implemented slice:

  • Requires source, trust, classification, destination connection, and destination trust zone.
  • Denies routes that have not been registered exactly.
  • Projects nested arrays using explicit paths such as orders.*.status.
  • Applies opt-in structured transforms after projection; redact() supports exact and wildcard paths and replaces matching values with [REDACTED] by default.
  • Rejects a custom ContextTransformer if its output expands the projected field set.
  • Returns an empty payload on a policy denial.
  • Records disposition, route, classification, projected-path fingerprints, transform and transformed-path fingerprints, and a released-payload fingerprint without recording raw values.

Structured redaction is deterministic substitution, not PII detection or anonymization. Custom transforms run inside the application and must be reviewed like other security-sensitive code. Tokenization, pluggable PII detectors, free-text scanning, and post-scrub validators remain planned. The intended broader pipeline is:

exact destination-route policy
    -> structured field projection
    -> derived values and tokenization
    -> pluggable scanning of unstructured text
    -> final post-scrub validation
    -> redacted evidence

PII detection is imperfect. Verdict should support deterministic projection and pluggable detectors, report what was removed or transformed, and never claim that arbitrary free text has been proven free of personal information.

A provider name alone is not a sufficient trust boundary. Ollama may refer to a local process or a remotely configured endpoint. The implemented route policy therefore applies to the resolved connection and trust zone, not a model-provider enum.

Provenance

Untrusted instructions can enter through more than the user's message:

  • Retrieved product descriptions and knowledge-base documents.
  • Tool results and external API responses.
  • Uploaded files.
  • Search and web content.
  • Stored conversation memory and summaries.
  • MCP tools and servers.
  • Another agent.

Verdict can preserve explicitly supplied source, trust, classification, and channel labels through its provenance ledger. A summary of an untrusted document does not become trusted merely because a model produced the summary.

Laravel AI prompt and tool-result integration

Synchronous Laravel AI agents can opt into prompt provenance with middleware. Trust and data class are intentionally selected by the application; the source defaults to user:agent-prompt only:

use Fissible\Verdict\Context\DataClass;
use Fissible\Verdict\Context\Trust;
use Fissible\Verdict\Evidence\ProvenanceLedger;
use Fissible\Verdict\LaravelAi\VerdictProvenanceMiddleware;
use Laravel\Ai\Contracts\HasMiddleware;

final class StorefrontAgent implements HasMiddleware
{
    public function middleware(): array
    {
        return [new VerdictProvenanceMiddleware(
            provenance: app(ProvenanceLedger::class),
            trust: Trust::Untrusted,
            dataClass: DataClass::Internal,
        )];
    }
}

The middleware records the original prompt, leaves the AgentPrompt unchanged, and avoids duplicating the user entry when Laravel AI resumes an approval. Laravel AI's synchronous middleware stage does not expose its generated invocation ID; Verdict carries the original prompt to the released PromptingAgent event and records it there. If an application retrieval middleware has a correlation ID, it should record a document before appending it:

$provenanceMiddleware->recordRetrievedDocument(
    correlationId: $invocationId,
    document: $document,
    source: Source::external('catalog-search'),
    trust: Trust::Untrusted,
    dataClass: DataClass::Internal,
    componentLabel: 'retriever',
    componentVersion: 'v1',
);
$prompt = $prompt->append($document['body']);

Tool results are recorded only for tools that explicitly implement ClassifiesToolResult. The contract supplies source, trust, and data class; Verdict fingerprints the result and never writes its raw value, arguments, or attachments to evidence. Unclassified tools produce no tool-result provenance entry. This integration currently targets synchronous Laravel AI events; streaming behavior remains separately unproven.

The practical limits of provenance tracking will depend on the integration points Laravel AI exposes. Verdict should document those limits rather than imply visibility it does not have.

Decisions, confirmation, and replay protection

Proposed decisions are:

Permit
Deny
RequireConfirmation
RequireReview
Throttle

Confirmation should approve a canonical envelope, not model-written prose. If the resource or any material argument changes, the prior approval should no longer apply.

An approval nonce and an execution idempotency key have different jobs:

  • A nonce is unpredictable and single-use. Reuse is rejected to prevent approval replay.
  • An idempotency key is intentionally reused for retries of the same logical operation. It prevents a timeout or queue retry from performing the side effect twice.

The current BoundTool slice implements approval as an internal receipt state machine. The Laravel AI tool-call ID identifies the pending call, while Verdict creates a separate unpredictable receipt ID for the approval decision. The model generates neither the receipt ID nor the decision-maker identity. Approval receipts authorize one exact proposal; they do not identify repeated versions of the same logical operation.

Strict at-most-once executor admission

A mutating capability may opt into durable duplicate admission prevention. This and the rate-limit example below reuse the application-defined $orderTargetPolicy from the capability example:

use Fissible\Verdict\ExecutionClaims\ExecutionClaimPolicy;

$capability = Capability::usingPolicy(
    name: 'orders.cancel',
    ability: 'cancel',
    resolveTarget: fn (ActionEnvelope $envelope): Order => Order::find(
        $envelope->proposal->arguments['order_id'],
    ) ?? throw TargetNotResolvable::make(),
)->executionTarget($orderTargetPolicy)->atMostOnce(ExecutionClaimPolicy::named(
    name: 'cancel-order-version',
    keyUsing: fn (ActionEnvelope $envelope, Order $order): array => [
        'tenant_id' => $order->tenant_id,
        'actor_id' => $envelope->context->actor->getAuthIdentifier(),
        'order_id' => $order->getKey(),
        'order_version' => $order->updated_at?->getTimestamp(),
    ],
))->executeUsing(fn (AuthorizedAction $action) => CancelOrder::run($action->target));

The application-defined binding is the identity of the logical operation. Include every material semantic input and trusted resource version that should distinguish one operation from another. Do not use the provider tool-call ID as that identity: transports may redeliver a call under a new ID, and the same ID may be reused incorrectly. Verdict hashes the capability, policy name, and canonical binding before storage; raw binding values are not retained.

The claim is the final gate immediately before the executor. Authorization and non-mutating approval validation run first, a configured semantic rate limit consumes an authorized-attempt unit, an applicable approval receipt is atomically consumed, and then the claim is atomically admitted. One caller enters the executor. Later or concurrent duplicates are denied while the claim is active, completed, or indeterminate.

If the executor throws after admission, Verdict marks the outcome indeterminate and rethrows the original exception. It does not guess whether an external side effect occurred, silently retry, or cache a potentially sensitive result. An operator must investigate and reconcile the claim:

php artisan verdict:execution-claims
php artisan verdict:resolve-execution-claim CLAIM_ID completed \
    --by=operator:7 --reason="Carrier confirmed cancellation succeeded"
php artisan verdict:resolve-execution-claim CLAIM_ID retryable \
    --by=operator:7 --reason="Carrier confirmed no request was accepted"

Resolving a claim as retryable releases it for one explicit retry. A claim still marked active requires --force; that option should be used only after application-specific investigation. Claim rows are part of the guarantee horizon, so Verdict provides no automatic pruning command.

This is strict at-most-once admission to the configured executor, not exactly-once effects. A process can fail after an external service accepts a request but before Verdict records completion. Executors and downstream APIs still need appropriate transaction, outbox, and idempotency designs. See ADR 0002 for the precise boundary.

Warning

Do not wrap the entire Verdict invocation in a database transaction on the durable Verdict stores' connections. Database-backed security-state stores detect an existing transaction and throw UnsafeOuterTransaction before mutation rather than allowing an outer rollback to erase a claim, restore a consumed approval, or refund a rate-limit unit after execution. An executor that opens and commits its own transaction inside executeUsing after admission does not have this problem. Configure approvals.connection, rate_limits.connection, and execution_claims.connection to use a separately committed security-state connection when an outer transaction cannot be avoided. See ADR 0004.

Rate limits and risk budgets

Laravel should continue to handle ordinary HTTP and queue throttling. Verdict intends to add semantic limits that understand the principal, tenant, capability, resource, and decision phase.

The first implemented slice limits authorized execution attempts for a capability using an atomic, fixed-window store:

use Fissible\Verdict\RateLimits\RateLimitPolicy;
use Fissible\Verdict\Exceptions\TargetNotResolvable;

$capability = Capability::usingPolicy(
    name: 'orders.lookup',
    ability: 'view',
    resolveTarget: fn (ActionEnvelope $envelope): Order => Order::find(
        $envelope->proposal->arguments['order_id'],
    ) ?? throw TargetNotResolvable::make(),
)->executionTarget($orderTargetPolicy)->rateLimit(RateLimitPolicy::fixedWindow(
    name: 'per-customer',
    limit: 10,
    windowSeconds: 60,
    keyUsing: fn (ActionEnvelope $envelope, Order $order): array => [
        'tenant_id' => $envelope->context->metadata['tenant_id'],
        'customer_id' => $envelope->context->actor->getAuthIdentifier(),
    ],
    reason: 'Order lookup limit exceeded.',
))->executeUsing(/* ... */);

The application defines the bucket from trusted context and the server-resolved target. Verdict hashes that binding together with the capability, policy, and window before it reaches storage. The unit is consumed after authorization and successful non-mutating approval validation, but before atomic approval-receipt consumption. Policy denials, pending confirmations, and ordinary unsuccessful approval checks do not consume it. A validation/consumption race can waste a time-recoverable unit, but cannot execute without winning receipt consumption. Database failures stop execution and remain visible as application faults rather than being mislabeled as throttles.

A consumed unit means an authorized execution attempt, not a guaranteed successful external side effect. A duplicate can therefore consume a unit before an execution claim blocks it; this is intentional and also bounds repeated pressure on an unresolved claim. See ADR 0001 for the precise boundary.

Later semantic limits may include:

  • Proposal attempts per principal and capability.
  • Denied cross-resource attempts.
  • Successful executions per resource.
  • Tool calls or planner calls per conversation.
  • Token or cost budgets per tenant.
  • Cumulative risk across individually permitted operations.

Stateful abuse matters. Ten separate permitted credits can be abusive even if each one passes the same Policy. Verdict's evaluation model should eventually support multi-turn, cross-session, and multi-capability scenarios rather than treating every prompt in isolation.

Evidence without collecting everything

Useful evidence may include:

run and correlation IDs
agent class
resolved provider and model
application build and policy version
rendered prompt or instruction hash
source and trust labels
principal and tenant references
capability and normalized arguments
resources resolved by the application
authorization decision and reason code
approval and idempotency metadata
rate and budget decisions
redaction counts
actual execution disposition
latency, token usage, and available cost data

The evidence store may contain highly sensitive information. The planned design will need configurable evidence levels, retention, tenant isolation, access authorization, pruning, and encryption. A hash of predictable personal information is not anonymization.

The implementation records action decisions, context-release decisions, and explicit context provenance through an EvidenceRecorder contract. Action records include stage, envelope ID, capability, detailed internal reason, timestamp, and a deterministic SHA-256 fingerprint of normalized arguments. Target-refresh records contain policy, strategy, proposal/execution identity fingerprints, and the match result. Confirmed bound executions distinguish proposal validation, execution validation, and atomic consumption while retaining only a hashed receipt reference. Context-release records contain the labeled route, classification, disposition, field-path and transform fingerprints, transformation count, and released-payload fingerprint. Decision and context-release records include neither raw arguments nor released payload values.

Callers can explicitly record which labeled inputs entered one invocation without retaining those inputs:

use Fissible\Verdict\Context\ContextChannel;
use Fissible\Verdict\Context\DataClass;
use Fissible\Verdict\Context\Source;
use Fissible\Verdict\Context\Trust;
use Fissible\Verdict\Facades\Verdict;

$entry = Verdict::provenance()->record(
    correlationId: 'invocation-01J4Z8X9',
    source: Source::external('knowledge-base'),
    trust: Trust::Untrusted,
    dataClass: DataClass::Internal,
    channel: ContextChannel::RetrievedDocument,
    content: ['title' => $document->title, 'body' => $document->body],
    componentLabel: 'catalog-retriever',
    componentVersion: 'v2.1.0',
);

$inputs = Verdict::provenance()->forCorrelation('invocation-01J4Z8X9');

content must be a scalar, null, or a nested native array of those values. Associative keys are sorted recursively before hashing, while list order and scalar types remain significant. The raw content and component version are fingerprinted before the recorder is called and are absent from ProvenanceEntry serialization and database rows. The optional component label is retained as readable evidence, so it must be a stable non-sensitive identifier rather than a filename, URL, or metadata value. Correlation, component, and version labels accept only letters, numbers, dots, underscores, and hyphens.

Verdict does not infer trust or classification. The application must provide Source, Trust, DataClass, and ContextChannel for every record, and a summary of untrusted input remains untrusted unless the application explicitly establishes a different policy. Recorder failures propagate as application faults; callers must not treat a thrown write as successful provenance.

Content and component fingerprints are deterministic. A hash of a predictable prompt, identifier, version, filename, URL, or personal value can be guessed and must be treated as correlation—not anonymization, encryption, or proof that the underlying input is safe. The ledger records only inputs explicitly passed to it; it does not parse rendered prompts, observe provider internals, or recover provenance that an integration discarded.

The default recorder is a no-op because silently choosing a storage destination or retention policy would be unsafe. InMemoryEvidenceRecorder exists only for tests and local development. It is unbounded process-local state and must not be used with production, Octane, queue workers, or as a tenant-separated evidence store.

An opt-in DatabaseEvidenceRecorder persists all three record types. Before persistence it hashes the provider tool-call/idempotency key; it never stores the raw key. It does persist detailed internal reasons, route labels, capabilities, and correlation IDs, which may still be sensitive application metadata. Applications remain responsible for database encryption, tenant isolation, access authorization, retention, export, and deletion policies. No pruning command or execution-outcome record exists yet.

Verdict should record observable inputs, outputs, policy facts, and decisions. It should not request or store hidden model chain-of-thought.

The database adapter is an ordinary mutable audit store. It is not append-only, immutable, signed, or tamper-evident and must not be described as cryptographic proof. A tamper-evident adapter may be offered separately in the future.

Security signals and containment

Verdict intends to emit normalized Laravel events for security-relevant behavior, potentially including:

CapabilityDenied
CrossTenantResourceRequested
SensitiveDataRedacted
ContextReleaseDenied
ApprovalReplayDetected
ExecutionRateExceeded
AgentBudgetExceeded
ToolLoopDetected
ProviderDestinationChanged

Applications could route these signals to logs, OpenTelemetry, a SIEM, or their own analytics. Verdict does not currently plan to build a general anomaly-detection platform.

Possible containment primitives include disabling a capability, agent, tenant integration, or principal. Automatic containment would need application-defined, high-confidence rules so an attacker cannot trivially cause a denial of service by producing suspicious-looking traffic.

Security evaluation

Laravel AI fakes are useful for testing application wiring. A fake response cannot establish that a real model resists an attack. Verdict's first implemented evaluation slice therefore measures deterministic application-boundary behavior; live-model trials remain planned.

An evaluation case labels trusted setup separately from untrusted input, invokes an application-supplied runner, and evaluates a structured Observation:

$crossCustomer = EvaluationCase::attack(
    id: 'cross-customer-order',
    version: '1',
    input: new CaseInput(
        trustedSetup: ['actor_id' => 72],
        untrustedInput: ['request' => 'Where is order #1001?'],
    ),
    runner: fn (CaseInput $input): Observation =>
        Observation::fromExecutionResult($sandbox->run($input)),
    assertions: [
        Assertions::decisionIs(Disposition::Deny),
        Assertions::notExecuted(),
        Assertions::toolDidNotExecute('orders.view'),
        Assertions::noSideEffects(),
    ],
);

$ownedOrder = EvaluationCase::utility(
    id: 'owned-order',
    version: '1',
    input: new CaseInput(
        trustedSetup: ['actor_id' => 72],
        untrustedInput: ['request' => 'Where is order #1002?'],
    ),
    runner: fn (CaseInput $input): Observation =>
        Observation::fromExecutionResult($sandbox->run($input)),
    assertions: [
        Assertions::decisionIs(Disposition::Permit),
        Assertions::executed(),
    ],
);

$result = (new SecuritySuite(
    name: 'storefront-boundary',
    version: '1',
    cases: [$crossCustomer, $ownedOrder],
    reproduction: new ReproductionMetadata([
        'policy' => 'storefront-order-policy@1',
        'proposal' => 'captured-proposal@1',
    ]),
))->run();

$containment = $result->score(CasePurpose::Security);
$utility = $result->score(CasePurpose::Utility);

This API is implemented but unstable. Observation::fromExecutionResult() captures the final disposition, whether the action executed, the capability, and the argument fingerprint. A caller must explicitly supply observed side-effect names; Verdict hashes those names in the returned observation evidence. Built-in assertions currently cover disposition, execution, named tool execution, named side effects, and forbidden values in string or structured output.

Case results retain trusted-setup and untrusted-input fingerprints, assertion outcomes, a redacted observation summary, and application-supplied reproduction components. They do not retain raw case inputs or raw outputs. Runner or assertion exceptions are reported as harness errors using the exception class only; they are not counted as behavioral failures or passes. A score's pass rate uses completed cases, while its error count remains separate. Reproduction component values and custom assertion names or failure messages are included verbatim, so applications must not put secrets or raw adversarial content in those strings.

$result->report() exports that data as an array or JSON using the versioned verdict.evaluation-report.v1 schema. The report starts as an in-memory representation. Verdict can validate and persist it as a repo-native baseline, but does not sign or upload reports.

A redacted JSON report can be checked into the application repository as a baseline and compared with a current run:

$baseline = EvaluationBaseline::fromJson(
    File::get(base_path('tests/Baselines/storefront.json')),
);

$comparison = $result->compareTo($baseline);

if ($comparison->hasBlockingChanges()) {
    // Behavioral regression, harness error, or removed coverage.
}

The same workflow is available through Artisan. Baseline creation validates and canonicalizes the entire redacted report, refuses to overwrite an existing file unless --force is explicit, and uses a same-filesystem atomic replacement:

php artisan verdict:evaluation-baseline \
  storage/app/verdict/current.json \
  tests/Baselines/storefront.json

php artisan verdict:evaluation-compare \
  storage/app/verdict/current.json \
  tests/Baselines/storefront.json

Use --format=github on the comparison command to emit escaped GitHub workflow annotations. Both formats return exit code 0 when no blocking change exists, 1 for a behavioral regression, new failure, harness error, or removed coverage, and 2 for malformed or missing files and command usage errors. Annotations contain only typed report fields and fingerprints; raw model input, output, or arbitrary unknown JSON fields are never printed.

A GitHub Actions comparison step can fail the workflow on blocking findings while leaving the grouped annotations on the run:

- name: Compare Verdict evaluation baseline
  run: php artisan verdict:evaluation-compare current.json baseline.json --format=github

Comparison keeps behavioral regressions, newly observed failures, harness errors, improvements, recoveries, added coverage, and removed coverage distinct. A newly added failing case records both the coverage addition and its behavioral failure. Changing a case from security to utility is represented as removed security coverage plus added utility coverage. Verdict does not yet provide a managed baseline store, statistical thresholding, or additional CI formats.

The package does not yet provide attack packs, live-provider runners, repeated trials, baseline services, additional report exporters, or automatic sandboxing. Application runners must use synthetic data and reversible or isolated executors. Live evaluations must eventually be explicitly invoked outside the ordinary deterministic test command.

The longer-term evaluation design retains these requirements:

  • Versioned attacker playbooks.
  • Trusted setup and explicitly labeled untrusted payloads.
  • Expected allowed behavior and forbidden outcomes.
  • Assertions against proposals, decisions, tool calls, resources, disclosures, and side effects.
  • Repeated live trials rather than treating one pass as proof.
  • Provider errors reported separately from behavioral failures.
  • Reproduction metadata and security regression baselines.
  • Sandboxed handlers so an evaluation cannot issue a real refund, email, shipment, or deletion.

An agent that denies everything is not useful. Reports should keep security containment and legitimate task success separate:

Security containment:     97%
Legitimate task success:  82%

Verdict is not intended to become a general response-quality evaluation library. Its evaluation focus is the security of application context, capabilities, policy decisions, and side effects.

LLM-as-judge assertions may be useful for semantic questions, but deterministic assertions should decide deterministic facts such as whether a forbidden tool ran or whether a resource belonged to the authenticated principal. Evaluator prompts must also be treated as exposed to adversarial content.

Storefront security lab

The workbench contains the first deterministic slice of a customer-facing eCommerce security lab. It currently covers order lookup, shipment refresh limits, and cancellation; product search, cart operations, and returns remain planned.

The most reproducible demonstration does not depend on successfully jailbreaking a model twice. It captures one model proposal and passes the same proposal through protected and unprotected execution paths.

Example:

Authenticated principal: customer_72
Request:                 "Where is order #1001?"
Resolved order owner:    customer_91
Model proposal:          orders.view(order_id: 1001)

The implemented comparison honestly shows three implementations:

Implementation Expected result
Naive raw Laravel AI tool Returns another customer's order
Manually secured tool using a scoped query or Policy Correctly denies access
Verdict capability using the same Laravel Policy Denies and records the full policy decision and evidence

The point is not that Laravel needs Verdict to perform an ownership check. The point is that Verdict aims to make the secure pattern consistent, inspectable, and regression-tested across the application's AI action surface.

The demo UI shows:

untrusted input | model proposal | policy decision | observed side effect

It lives entirely in the package workbench and does not add routes, views, or frontend assets to the distributed package. It also contains four independent labs:

  • Argument-bound cancellation approval: changed arguments fail, the exact approved action executes once, and replay fails.
  • Semantic shipment-refresh limit: Laravel permits three owned-order requests individually while Verdict enters the carrier executor only twice and records the third aggregate attempt as a throttle.
  • Destination-bound context release: an allowlisted customer projection is authorized and prepared for a local Ollama connection, its explicitly allowed email is redacted, and the same provider name in a remote trust zone is denied.
  • Deterministic security evaluation: the cross-customer attack and owned-order utility paths run through the actual Verdict capability, then render separate scores and a redacted versioned report.

The primary path intentionally uses a captured proposal rather than a live provider. Holding the proposal constant makes the authorization comparison reproducible and requires no credentials. An optional live-model path remains planned; it should feed its proposal into the same execution comparison rather than treating successful exploitation as deterministic.

Additional demo cases may include indirect injection in a product document, refund abuse, free-text PII detection, and cross-session risk budgets.

Relationship to Laravel AI

Laravel AI already provides the model-facing runtime. Verdict should use its public extension points and remain a thin adapter around them.

Laravel AI owns Verdict intends to add The application still owns
Agents and prompts Capability envelopes Authentication and tenancy
Provider and model selection Context-release decisions Laravel Policies and business rules
Tool schemas and invocation Mandatory authorization phase Domain services and commands
Structured output Provenance and sensitivity labels Credentials and provider agreements
Conversations and streaming Security evidence and signals Retention and compliance decisions
Human approval mechanics Argument-bound approval policy Operator and customer experience
Generation limits and events Security suites and regressions Incident-response program

Provider-side tools may execute outside the application's local tool handler. Verdict must publish an enforcement matrix that distinguishes what it can block, what it can only observe, and what it cannot see.

Headless by default

The package is intended to work without Blade, Livewire, Inertia, Filament, or a JavaScript framework.

The initial package should provide contracts, events, evidence records, decisions, commands, and serializable data needed to build an interface. A development viewer or Filament integration may be useful later, but it should remain optional.

Approval interfaces must be generated from trusted envelope data rather than model-authored HTML or prose. Public denial messages should remain separate from detailed internal reasons.

Threat model

Verdict is intended to help when:

  • Direct or indirect prompt injection manipulates a model.
  • A model proposes a tool with unauthorized resource IDs or arguments.
  • A model or user attempts cross-tenant access.
  • An approval is replayed or altered.
  • Queue retries or concurrency could duplicate a side effect.
  • Tool results, documents, memory, or peer agents introduce untrusted instructions.
  • Excessive or cumulative use indicates workflow abuse.
  • A model, prompt, tool, policy, or document change causes a measurable security regression.

Verdict will not, by itself:

  • Prevent all prompt injection or jailbreaks.
  • Prove that an agent is secure.
  • Make arbitrary free text provably free of PII.
  • Correct vulnerable business logic inside an executor.
  • Replace Laravel Policies, cloud IAM, OAuth servers, DLP, secrets management, or network controls.
  • Secure the infrastructure or data-retention practices of a model provider.
  • Observe or block every action performed inside provider-hosted tools.
  • Establish factual correctness or provide general content moderation.
  • Turn untrusted MCP servers into trusted code.
  • Provide a compliance certification.

The intended claim is narrower:

Verdict aims to limit the impact of manipulated model behavior at the Laravel application boundary, record deterministic authorization evidence, and test whether those controls continue to work.

Secure Laravel implementation details that matter

The implementation will need to account for ordinary Laravel runtime behavior:

  • Principal, tenant, and action context must use lifecycle-scoped state rather than a singleton that could leak between Octane requests or queue jobs.
  • Queued execution must rehydrate identity and re-authorize instead of trusting a serialized decision forever.
  • Mutating handlers need idempotency independent of queue uniqueness.
  • Streaming output cannot be retracted after it has been sent; sensitive response checks may need buffering or documented limitations.
  • Policy and capability registration must work with cached configuration and long-running workers.
  • Live-model evaluations must not run as part of an ordinary deterministic test command.

The confirmation receipt slice implements lifecycle-scoped approval context and database-backed, single-use receipts. The execution-claim slice prevents duplicate admission only on configured paths through Verdict. The remaining items are design requirements, not implemented guarantees.

Package shape

The repository is one headless Laravel package:

fissible/verdict
    src/
        Capabilities/
        Actions/
        Decisions/
        Approvals/
        Evidence/
        Evaluation/
        LaravelAi/
        Policies/
    tests/
    database/
    workbench/

The package is scaffolded from the conventions in Laravel's official package-skeleton, using its Testbench workbench for the demo while keeping the distributed package headless.

Splitting a framework-independent core or optional UI into separate packages should happen only after a real boundary and consumer appear.

Installation

Verdict is available on Packagist:

composer require fissible/verdict:^0.1

The current constraints are PHP 8.3+, Laravel 12 or 13, and laravel/ai 0.10.2 or newer within the 0.10 line. Laravel AI is pre-1.0, so Verdict verifies its adapter against released public contracts and should expect compatibility work as that SDK changes.

The supported developer-preview surface and release checklist are documented in RELEASES.md. Do not infer support for a new Laravel AI minor from Composer being able to resolve it; each minor compatibility band requires an explicit Verdict review and release.

Live evaluations will require developers to supply their own provider credentials and accept the associated provider costs and data-processing terms. Deterministic package tests should not require provider credentials.

Run the storefront security lab

The demo runs from Testbench and uses only synthetic data:

composer install
composer build
php vendor/bin/testbench serve

Open the displayed local URL. The default cross-customer scenario compares a naive Laravel AI tool, an explicitly secured Laravel implementation, and Verdict's BoundTool using the same Policy. Independent labs exercise argument-bound confirmation, semantic rate limits, strict at-most-once executor admission across changed provider call IDs, destination-bound context release, and deterministic security evaluation. A selector also runs the legitimate owned-order path.

The workbench configures process-local evidence and approval stores so the lab needs no production infrastructure. Those adapters are intentionally unsuitable for production, Octane, or queues.

Configuration

Verdict's service provider and Verdict facade are registered automatically through Laravel's package auto-discovery; no manual registration is required.

Verdict ships a config/verdict.php file. Publish it to customize the runtime adapters:

php artisan vendor:publish --tag=verdict-config

Verified confirmations use a database receipt store by default. Publish and run its migration before enabling requiresConfirmation(...):

php artisan vendor:publish --tag=verdict-migrations
php artisan migrate

Database evidence is opt-in. Select DatabaseEvidenceRecorder::class in the published Verdict configuration, then publish its migrations and migrate. Existing installations receive an additive provenance migration; existing action and context-release rows remain valid:

php artisan vendor:publish --tag=verdict-evidence-migrations
php artisan migrate

Semantic execution limits use an atomic database store by default. Publish and run the rate-limit migration before attaching a RateLimitPolicy to a capability. Expired fixed-window rows are not needed for enforcement and can be pruned on an application-defined schedule:

php artisan vendor:publish --tag=verdict-rate-limit-migrations
php artisan migrate
php artisan verdict:prune-rate-limits

At-most-once executor admission also uses an atomic database store by default. Publish and run its migration before attaching an ExecutionClaimPolicy to a capability:

php artisan vendor:publish --tag=verdict-execution-claim-migrations
php artisan migrate
  • approvals.store — the ApprovalReceiptStore implementation. The default database store uses atomic row-locked transitions. The in-memory implementation is only for deterministic tests.
  • approvals.connection and approvals.table — the database location for receipt state.
  • approvals.ttl_seconds — the default receipt lifetime; a capability may select a shorter or longer lifetime explicitly.
  • evidence.recorder — the EvidenceRecorder implementation used to record decisions. Defaults to NullEvidenceRecorder, which discards evidence, because silently choosing a storage destination or retention policy would be unsafe. InMemoryEvidenceRecorder is available for tests and local development; it is process-local and unbounded, and unsuitable for Octane, queues, or production.
  • evidence.connection and evidence.table — the database location used when DatabaseEvidenceRecorder is selected.
  • rate_limits.store — the RateLimitStore implementation. The database default coordinates across requests, workers, and nodes. InMemoryRateLimitStore is only for deterministic tests and local development.
  • rate_limits.connection and rate_limits.table — the database location for fixed-window bucket counters.
  • execution_claims.store — the ExecutionClaimStore implementation. The database default coordinates atomic admission across requests, workers, and nodes. The in-memory implementation is only for deterministic tests and local development.
  • execution_claims.connection and execution_claims.table — the database location for durable execution-claim state.
  • ai.denied_message — the message returned to the model when a proposal is not executed. Internal denial reasons are recorded in evidence but are never included in this message.

The receipt table intentionally retains terminal rows so an old tool-call cannot silently become a new approval after deletion. Execution claims likewise retain completed rows because deleting one re-enables admission for that logical operation. A bounded tombstone or retention policy has not been implemented; do not delete either kind of record while its replay guarantee is required.

Roadmap

This roadmap is directional and may change as the integration is prototyped.

Phase Scope Status
Design Threat model, vocabulary, package boundary, demo design Documented; ongoing
Runtime foundation Capability registry, bound and guarded tools, staged decisions, Laravel Policy integration First slice implemented
Identity and execution Principal/tenant binding, target freshness, confirmation state, expiry, duplicate admission, idempotency Explicit execution-target refresh/snapshot policies, confirmation receipts, and opt-in strict at-most-once executor admission implemented; transactional execution and exactly-once effects remain application-specific
Semantic limits Per-capability execution attempts, trusted bucket bindings, durable counters, throttle evidence Fixed-window execution-limit slice implemented; proposal, conversation, cost, and cumulative-risk budgets planned
Context release Source labels, field projection, PII scrubber contracts, destination policy Deterministic projection, structured redaction, transform non-expansion, and exact destination routes implemented; detectors and validators planned
Evidence Pluggable stores, redaction levels, security events, audit command Null, in-memory, and opt-in database recorders include explicit redacted provenance, target-refresh, and phased approval evidence; levels, retention tooling, events, and audit command planned
Evaluation Deterministic attack cases, live-model suites, baselines, reports Deterministic cases, assertions, redacted JSON reports, separate scoring, atomic baseline creation, and console/GitHub CI comparison implemented; live runners and statistical thresholds planned
Demo Sandboxed eCommerce assistant and security trace Deterministic authorization, confirmation, semantic-limit, at-most-once admission, context-release, and evaluation labs implemented; live-model path planned
Containment Kill switches and application-defined containment hooks Exploratory
Communications risk Provider-neutral RiskSignal and RiskAssessment contracts, evidence, policy decisions, and containment for fraud/scam-adjacent actions; reputation and telecom intelligence remain optional adapters Exploratory and adjacent; taxonomy, false-positive policy, and adapter boundaries require design before implementation
Optional UI Development viewer or framework-specific adapter Exploratory

Release milestones

  • v0.1.0 — runtime developer preview: released after the complete compatibility matrix and a clean Laravel consumer install passed.
  • v0.2.0 — provenance and live evaluation: implementation-ready issues cover the redacted provenance ledger, Laravel AI provenance hooks, the first deterministic attack pack, baseline/CI commands, and an opt-in repeated-trial live runner.
  • Later 0.x releases: operational events, distributed containment, evidence lifecycle, additional budgets, scam-resistance signals and policy integration, and optional detector adapters remain directional until separately scoped.

Post-v0.2 exploratory track: communications risk

This is deliberately an adjacent design track, not a commitment to make Verdict a general scam detection or consumer-protection product. The package boundary under consideration is:

  • RiskSignal — a provider-neutral, redacted observation from an application, telecom integration, reputation service, or evaluator. A signal should identify its category, source, confidence, freshness, and evidence fingerprint without pretending to prove that a person, number, message, or destination is fraudulent.
  • RiskAssessment — an application-scoped aggregation of signals and trusted context. It can expose reasons, uncertainty, and expiry to policy code, but it should not become an opaque, globally portable scam score.
  • Governance integration — policies may translate an assessment into permit, deny, require_confirmation, require_review, or throttle, with redacted evidence and evaluation results. High-risk communications should be able to require independent verification before an agent discloses information, sends money, follows a link, calls a supplied number, or initiates unusual communications activity.
  • Optional adaptersfissible/phone, Mesabit, and third-party services may collect domain-specific signals; Verdict should consume them at the authorization boundary rather than own a telecom reputation database. Caller-ID authentication is also not content authentication; the FCC describes STIR/SHAKEN as authenticating caller-ID information, not establishing that a call's claims are trustworthy.
  • Evaluation and evidence — a future deterministic pack should test scam-resistance controls alongside legitimate lookalikes, measuring both containment and task utility. Raw transcripts, phone numbers, and vendor payloads should not become the default evidence record.

The initial taxonomy and policy examples should be grounded in primary sources and versioned with their provenance. For example, the FTC identifies unexpected requests, urgency, prescribed hard-to-reverse payment methods, and independent verification as useful scam-resistance guidance; these are signals for an application's policy, not a universal classifier. This track should stay out of v0.2.0 until its scope and package boundary are separately accepted.

Every issue in the v0.2.0 milestone labeled scope: ready has selected design constraints and acceptance criteria suitable for an outside contribution. See CONTRIBUTING.md before starting a pull request.

Frequently asked questions

Why not just use tool calls?

Tool calls are the transport for a model's proposal. They do not inherently bind the proposal to the application's authenticated principal, canonical resource, Laravel Policy, approval state, rate limits, or idempotent execution.

Why not just put the rules in the system prompt?

Prompt rules guide model behavior. They are not an authorization mechanism. Untrusted content, model changes, hallucination, and ordinary ambiguity can all produce an unexpected proposal.

Why not just use a Laravel Policy?

You should use a Laravel Policy. Verdict intends to make invoking it a required stage for protected AI capabilities and to record and test that invocation. A Policy that is correctly called inside every tool may already prevent the authorization bug; Verdict adds consistency and the surrounding security lifecycle.

Does Verdict detect prompt injection?

Attack detectors may be useful signals, but the core design does not depend on correctly classifying every malicious string. It assumes the model can be manipulated and limits what a manipulated proposal can do.

Can different capabilities use different models?

That is planned as an optional planner strategy. Provider and model selection should not alter the capability's deterministic authorization or grant additional authority.

Will Verdict include a dashboard?

Not as a required dependency. The package is intended to be headless. The workbench demo can have a full interface, and optional development UI may follow later.

Origins and security foundations

Verdict grows out of work on fissible/llm-triage-eval and a broader interest in reproducible, evidence-backed evaluation of AI behavior.

The security model is informed by durable, vendor-neutral standards and research:

  • OWASP Agentic AI - Threats and Mitigations provides a threat-model-based reference for agentic systems, including scoped tools and privileges, session isolation and retention, sandboxed execution, behavioral monitoring, rate limits, traceable logs, and post-incident review.
  • OWASP LLM06:2025 Excessive Agency recommends minimizing tool functionality and permissions, executing actions in the user's security context, independently authorizing downstream actions through complete mediation, and requiring approval for high-impact operations.
  • NIST AI 600-1: Generative Artificial Intelligence Profile applies the AI Risk Management Framework to generative AI and covers threat modeling, retained test and evaluation history, provenance, red teaming, post-deployment monitoring, incident response, containment, and deactivation.
  • The peer-reviewed AgentDojo work demonstrates stateful, adversarial evaluation of tool-using agents over untrusted data, with explicit utility and security outcomes and support for evolving attacks and defenses.

Related security work includes the OWASP Top 10 for Agentic Applications. These sources do not endorse Verdict. Verdict may map attack packs and evidence to their relevant terminology, but such mappings would not constitute conformance or certification.

Feedback at this stage

This repository begins as a public design proposal. The most useful early feedback concerns:

  • Laravel AI execution paths that a guarded capability could miss.
  • Authorization, tenancy, approval, queue, and streaming edge cases.
  • Useful evidence that can be collected without retaining sensitive prompts.
  • Real agent-abuse incidents that should become reproducible attack cases.
  • The smallest API that would make secure behavior easier than an unguarded tool.
  • Places where the proposed scope duplicates Laravel or another focused package.

The API sketches will change. The invariant should not:

Models propose. Applications authorize. Verdict records and tests the boundary.