Skip to content

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 20 Aug 00:46
· 130 commits to main since this release
  • 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.