You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Decision-evidence records now carry an Attest-independent identity: a claimType saying what the
record asserts, and a scheme-tagged recordDigest naming which exact record it is. Both are derived,
additive, and computed with no dependency on fissible/attest. See #223 and docs/evidence-record-identity.md. Why it matters. A record's only cryptographic identity used to be Attest's hash chain, which coupled
"can another system reference this specific decision" to "did you adopt Attest." Identity (semantic,
Verdict's) and integrity (cryptographic, Attest's) are now separate: Verdict mints the identity from data
it already fingerprints, and AttestEvidenceRecorder places record_digest in the payload Attest signs,
so the signature covers it. Attest protects the identity rather than defining it — it cannot sign the
value directly, because it hashes its own envelope over its own RFC 8785 encoder. recordDigest is canonicaljson-sha256:<hash> over the record's stable fields, reproducible offline
from RecordDigest::stableFields() and CanonicalJson alone — including from a persisted row, which is
why recordedAt enters as UTC seconds rather than at a precision the timestamp column does not keep. reason is excluded, so an application cannot change a record's identity by rewording a message, and the
idempotency key enters as its fingerprint, never raw. No new raw or sensitive value is introduced.
The scheme tag keeps a future canonicalization additive rather than a re-identity of published records. claimType is a curated, public, additive-only vocabulary, not a mechanical verdict.<stage>.<disposition> — which would leak internal names into an external contract and mint verdict.execution.permit, a string that reads as "execution happened." The strongest execution-adjacent
label is verdict.execution.claim-completed, documented as an admission-side belief and never a receipt. Two stages needed a third key, and the exhaustiveness test is what found it.execution_claim + permit is emitted both when a claim is admitted — before the executor is called — and when it completes; approval + permit is emitted at three phases, one of which spends a single-use receipt. Keying the
vocabulary on stage+disposition alone would have labelled admissions as completions. Those stages key
on execution_claim_status and approval_phase respectively, and ClaimTypeVocabularyTest fails until
every tuple the state machine can emit is mapped or explicitly declared unreachable. ADR 0028 fixes the rules the vocabulary
obeys — curated never mechanical, keyed per stage, additive-only, and never implying that an execution
happened — so a future contributor cannot regenerate the map or rename a published label. The table
itself lives in docs/evidence-record-identity.md, cross-linked from the incident-response runbook and
the security model.
The execution-mode compatibility matrix has no unverified cells left: queued approval resumption is
verified through completion.QueuedApprovalResumptionTest dispatches a real InvokeAgent job onto
the database queue, runs queue:work --once --force, and asserts the worker paused on a confirmation
gate without executing; then approves the receipt in Verdict, dispatches a second job carrying a specific
tool-call decision, and asserts the capability executed exactly once. See #234 and #218. The previously-stated blocker was wrong, and the footnote now says so. It claimed InvokeAgent does
not retain the initial job's pending tool-call response. A resume never reads that response: the pending
call is reconstructed from conversation history, so a durable ConversationStore — not job state — is
what carries a paused turn across the boundary. The gap was coverage, not capability. A durable conversation store is therefore a requirement for queued approval flows, alongside the two
the streamed work surfaced: approve the receipt in Verdict, and resume with a specific tool-call decision.
The adoption guide's production-gate checklist states all three.
Two companion cases assert the refusals are real rather than absent — a wildcard-only resume and a resume
whose receipt was never approved in Verdict each execute nothing — and both first assert approval-stage
evidence exists, so a resume that never ran cannot pass itself off as a refusal.
Streamed approval resumption is now verified through completion, and the compatibility matrix footnote
says what backs it. StreamedApprovalResumptionTest drives a confirmation-gated capability through
Laravel AI's real stream() pipeline and asserts it pauses, does not execute before approval, and
executes exactly once on an approved resume. See #218. Two application requirements are now documented, because getting either wrong fails silently. The
receipt must be approved in Verdict through the application's own authenticated flow, and the resume must
carry a specific tool-call decision. Decision::approveAll() yields a wildcard '*' that ApprovalExecutionContext::push() deliberately skips — a blanket approval from the agent loop must not
authorize a specific consequential action. A resume missing either step executes nothing and looks like a
broken feature. The test uses a StepTextGateway, not Agent::fake(), and that is load-bearing. ResumesToolApprovals::resumableApprovalFor() returns null for a faked gateway, so a faked agent never
resumes tools and would report non-execution for a reason unrelated to Verdict.
A recorded live run against Ollama is published in docs/evaluation.md, alongside the five instrument
defects that produced convincing false negatives before it.
Documented that a passing tamper-evidence verification does not assert the record is complete, and that
since fissible/attest 1.3.0 the verification output says so itself. attest.cli.result.v1 carries a
constant completeness block whose asserted is always false, beside the separate verified field, so
a downstream tool can render "integrity verified" and "completeness not asserted" without parsing prose.
See #224 and attest#13. Two independent non-assertions, and the second is easy to miss. Content that bypassed instrumentation
never reached the chain to be signed — for Verdict that blind spot has a name, bypassed paths — and a
verification can be scoped to part of a chain, via attest:verify --from/--to or whatever range a
bundle's exporter chose. The caveat is in the JSON, not yet in the terminal.php artisan attest:verify --json carries it;
the command's human-readable output does not, because fissible/attest-laravel renders its own summary
lines rather than attest's. Tracked in attest-laravel#8; until it lands, an operator
reading the terminal relies on docs/limitations.md. fissible/attest moves to 1.3.0 in the lock file. It is a require-dev dependency here and optional for
adopters, so this changes nothing about what Verdict requires.
verdict:validate now names any capability that declares requiresConfirmation() with no
execution-target policy. That combination looks gated and never pauses: requestConfirmation() returns null without a target policy, so shouldRequestApproval() returns null, Laravel AI has nothing to
pause on, and the action is denied at execution without a human ever being asked. See #230. Advisory, because the failure is closed. The action does not execute — what is lost is the human
decision, not the boundary. The exit code does not move; --strict covers it like every other advisory
finding. Whether the combination should be rejected at registration is a separate, behavior-changing
question left open in #230, on the #150 precedent that a
declaration which can never do what it asks should fail rather than silently do nothing. The guards mirror requestConfirmation()'s own, so the warning fires exactly when that method would
decline to issue — not on a superset. A capability with no executor is already reported separately and is
not double-warned.
This trap cost a wrongly-filed defect issue and a reverted documentation change before it was found; the
warning exists so the next person meets it at deploy time instead.
verdict:validate now warns for each non-durable adapter configured outside local and testing: the
in-memory evidence recorder and the in-memory approval, rate-limit, execution-claim, and
capability-configuration stores. config/verdict.php has always said in comments that these are unsafe
outside local development, and nothing checked — a comment in a published file is read once, at vendor:publish, and never again. See #146. Warnings, not errors, and deliberately so. The exit code does not move. Verdict does not decide an
application's deployment topology, and an ephemeral preview environment or a smoke test may legitimately
run one of these. --strict is the opt-in for CI that wants to block, and it already covers every other
advisory finding the command reports. Each warning names its own consequence, not a shared one. The remedies differ in urgency: a
process-local rate limit multiplies a security bound by the worker count, a process-local approval store
means a receipt issued in one process cannot be consumed by the one that executes, and a process-local
configuration registry only makes retained evidence unreadable later. Every warning names the config key
to change alongside the hazard, on a separate line from the component warning, because components
truncate to the terminal width and the key is the half an operator acts on. Environment detection is the framework's own. The check keys off Laravel's local/testing
determination rather than a list of production-looking names, so an environment called staging, preview, or anything else is covered without configuration. It compares configuration, not resolved container bindings, and says so. A read-only wiring audit
reads what the deployment declared. An application that leaves config durable and rebinds a store
contract to a non-durable implementation in a service provider is invisible to it, in both directions,
and so is a custom store of the application's own that happens not to be durable. A clean run means
"nothing declared in configuration is non-durable", not "every store this application resolves is
durable".
A worked incident-response walkthrough, docs/incident-response.md. One
realistic incident taken from the alert to a written conclusion using only the shipped tables, with SQL
that is executed against the published migration stubs by tests/Feature/IncidentResponseQueriesTest.php
rather than reviewed as prose. Every step states what the evidence establishes and what it does not.
See #147. Two joins that look obvious are wrong, and the document leads with them.correlation_id holds the
action envelope id on a decision row and the invocation id on a provenance row, so joining decisions
to provenance on it returns nothing, silently — invocation_id is the only column that spans record
types. And approval_receipt_fingerprint, execution_claim_fingerprint, and idempotency_key_fingerprint are SHA-256 of the corresponding id, not the id, so none of them joins
directly to the operational-state tables. The provenance join is now pinned. An incident reconstruction reaches declared upstream content by
using a decision's argument_fingerprint as a child_content_fingerprint. That works because ArgumentFingerprint and ContentFingerprint share one canonicalization — which ProposalAnchorTest
described as a coincidence converted into a contract while only ever asserting it for ProposalAnchor::for(). ArgumentFingerprint::make(), the value that actually reaches the evidence row,
was unguarded: divergence would have returned no rows rather than erroring, reporting every proposal as
having no declared upstream. A mutation-checked test now holds it.
Register a capability by affirming it, not by wiring it. A class in app/Capabilities/ that implements
the new Fissible\Verdict\Contracts\DefinesCapability contract — one token added to a class the
generator already wrote — is discovered and registered at boot, through the same path Verdict::capability() uses. Discovered and hand-registered capabilities are the same object everywhere
downstream. Provider registration still works and is still supported. See ADR 0027 and #210. No upgrade break, and that is structural rather than lucky. The contract gates discovery, so an
existing app/Capabilities/ full of classes generated before this release implements nothing, registers
nothing, and fails nothing. Discovery is on by default only because that is true. The interface is an affirmation, not a proof. Verdict cannot see inside your closures and does not
pretend to — it cannot tell a finished capability from one whose TODOs still throw. A false affirmation
still fails closed: at boot if the definition throws while building, at first invocation otherwise.
Removing the interface is the supported way to park unfinished work; the class goes inert and verdict:validate names it. A definition is a declaration, not a service. The contract is static make(): Capability, so
discovery never resolves a definition from the container. An instance contract would resolve a
definition's collaborators at boot and hold them for the worker's life — the binding-lifetime defect #183 already cost this codebase once. Closures calling app() in their bodies resolve in the request scope they belong to, which is the correct pattern rather
than a workaround. Failures are reported together. A definition that affirms the contract and cannot be built fails the
boot with every other such failure listed at once — class, cause, and both ways to resolve it, per entry.
Registration is all-or-nothing, so a boot that is going to die never leaves a partial security surface
registered. verdict:validate in a deploy pipeline still fails with the complete list before production
boots the same code; it surfaces during the command's own bootstrap, which is the pipeline working rather
than the tooling breaking.
New config key verdict.capabilities.discovery.paths, defaulting to app_path('Capabilities') — where
the generator has always written. An empty array disables discovery. verdict:make-capability now emits
the contract import and a TODO directing you to affirm once the other TODOs are replaced; it never
affirms for you.
Record the tool description a model was actually shown. Verdict already fingerprinted the
description at wiring time and recomputed it on every description() call — a divergence between
the two is precisely the signal that a tool's advertised description changed after binding — and
then discarded both. Decision evidence now carries tool_description_fingerprint, invocation_tool_description_fingerprint, and an indexed tool_description_matched, so an
operator can find divergences rather than having to suspect them. A migration adds the columns and
both durable recorders map them. See #163. tool_description_matched is null, not false, when the description was never advertised: a tool
invoked without a prompt build was not observed, and reporting that as a match would claim an
observation nobody made. This is a forensic gap being closed, not an authorization one. A poisoned description cannot
redirect execution — the capability is passed explicitly to Verdict::bound() and never derived
from description text. Recording a divergence does not deny, warn, or dispatch an event; whether it
should is a separate decision and is not made here.
State and enforce what may enter a binding fingerprint. ArgumentFingerprint decides when two
requests are the same request — it is the approval receipt's bindingFingerprint, the execution
claim's, the rate-limit bucket identity, the evidence argument_fingerprint, and the context
release's payload_fingerprint — so it now refuses what it cannot canonicalize reliably instead of
hashing it and hoping. The contract is scalars, null, and arrays of those, stated in ADR 0013 and enforced identically by ContentFingerprint. See #152. Upgrade note — objects are now refused. Passing an object into a fingerprinted structure throws InvalidArgumentException. This affects applications that return domain objects from requiresConfirmation(bindUsing:) or atMostOnce(binding:) callbacks, or that release a payload
containing an object such as a DateTimeInterface. Convert to an array of scalars at that point
($order->id, $at->format(DATE_ATOM)).
The previous behavior was not a working feature: JsonSerializable put an application-defined
method inside the binding computation, non-public properties were dropped silently, and (object) ['a' => 1] collided with ['a' => 1] — a different PHP type treated as the same
authorized request. The failure mode it replaces is an approval that silently stops matching the
action it authorized when an unrelated private property is added. Upgrade note — float rendering.json_encode renders floats according to serialize_precision, so the same value fingerprinted differently across deployments, and across
one deployment either side of an ini change — leaving an already-issued approval impossible to
consume. The encoder now pins that setting to PHP's default for the duration of the call and
restores the caller's afterwards. Deployments running the default (-1, unchanged since PHP 7.1)
see no digest change at all. A deployment that has set serialize_precision to something else
will see fingerprints containing floats change once: in-flight approval receipts and open execution
claims with float bindings will not match after the upgrade and must be re-approved or re-claimed.
The failure is fail-closed.
A test pins the digest of a fixed structure, so any future change to canonicalization breaks the
build rather than silently invalidating persisted receipts.
Surface a proposal's declared provenance to a human approver. ApprovalChallenge gains a ProposalProvenance payload describing each declared upstream source by identity, trust, data
class, and channel — never content, and never a fingerprint of it. An approver clicking through a
tenth identical-looking refund challenge now has a signal that the tenth one came from an injected
document. Verdict already recorded this; it was only ever available for post-hoc audit, never at
the one moment a human could act on it. See ADR 0026 and #195. Declared derivations only. Everything in an invocation shares a correlation id, so what was
retrieved during it is trivially answerable — but that is not what caused this proposal, and
presenting it as such would manufacture a causal claim the ledger deliberately refuses to make. Absence is reported, never implied.ProvenanceDisclosure distinguishes Declared from Unknown (the ledger was consulted; nothing was declared) from Unreleased (no approver release
policy is registered, so nothing was disclosed at all). Sources that were declared but could not be
described are counted rather than dropped. The payload is a context release, not an exemption from one. It travels the ADR 0008 allowlist
path, and Verdict registers no default policy for the approver route — a default would be Verdict
authorizing a release on the application's behalf. Register a ReleasePolicy between ApproverAudience::source() and ApproverAudience::destination(); until then every challenge
reports Unreleased, and verdict:validate warns when confirmation-gated capabilities exist
without it.
Applications declare a proposal's origin against ProposalAnchor::for($arguments) — the one
supported way to compute the anchor, because a hand-rolled hash of the same arguments is
unreachable by construction and fails silently. verdict.approvals.strict_provenance (default false) denies an unattributable consequential
proposal at the confirmation gate. It is meant to stay off until an application's declarations are
thorough enough to trust; enabling it with no approver route registered is self-defeating and
refuses at boot.
A migration adds a nullable provenance column to verdict_approval_receipts: the payload is
assembled when the receipt is issued, inside the invocation, because the challenge is rendered
later in a request that has no invocation frame. Receipts issued before this column existed read
as an absent payload — not as Unknown, which would claim the ledger was consulted.
Known gap: lineage declared in a different invocation (ingestion-time chunk ← uploaded PDF) does
not reach the approver. Tracked in #201.
Add Capability::usingPolicyForContextTarget(), a capability whose target resolver receives an ActionContext rather than an ActionEnvelope — so the model's proposal is not in scope and an
injected argument cannot redirect which record is acted on. The guarantee is enforced by the
parameter type, not declared: a declaration would still receive the envelope and could be
contradicted on the next line. usingPolicy() is unchanged and remains correct where a model legitimately chooses among
candidates. What changes is that the two are now distinguishable — at the call site, and in
evidence. DecisionEvidence gains targetSource (context or proposal), recorded per decision so an
auditor can query the population that matters: proposal-resolved consequential capabilities. It is
deliberately not folded into the configuration fingerprint, which is a hash and cannot answer that
question without being recomputed. The field names the constructor that was used, never a verified property of the closure body.
Verdict cannot see inside a resolver, so a usingPolicy() capability records as proposal-resolved
even if its closure happens to read only context. Bounding selection also leaves the executor
unconstrained and does not make intent determinable — limitation.intent remains untestable.
The field is persisted: a migration adds an indexed target_source column and both durable
recorders map it, so the auditor query the field exists for actually runs against a real store.
Demonstrated by #187's deterministic differential.
See #192 and ADR 0025.
Distinguish a live run the harness could not observe from one the model declined. The coverage
gates measured coverage of observations, not integrity of the observation pipeline, and pooled four
error categories into one bucket: Declined and NotAttempted — what the model chose — alongside Unavailable and Uncategorized — what the apparatus could not see. A run blinded by a harness
defect therefore reported the same disposition as a run where the model was merely uncooperative. #183 is the worked instance: every reachable case
failed correlation and the command reported NOT EVALUATED, which is arithmetically correct and
reads as a finding about the model when the harness saw nothing at all.
The population is now partitioned four ways, LiveEvaluationThresholdDisposition gains HarnessBlind, and that check runs before any coverage or rate question — placing it after
them launders an apparatus failure into a measurement verdict. A trial that measures nothing while
something is harness-blind halts the run, a signature an uncooperative model cannot produce because
declines never enter that bucket. Both renderers and the JSON report carry the harness-blind count.
The coverage rule still counts harness-blind outcomes against coverage: an outcome the apparatus
could not see is still one that was not measured, so splitting the bucket for reporting does not
shrink the numerator of ADR 0021's test. This does not make the harness self-validating. It detects blindness that manifests as
uncorrelatable or unclassifiable outcomes. A harness that observes the wrong thing confidently
still passes every gate here. See #185 and ADR 0024.