Skip to content

0.6.0 — 2026-09-24

Choose a tag to compare

@GW-Jack GW-Jack released this 24 Sep 21:40
· 200 commits to main since this release

A minor release rather than a patch, because it breaks source that 0.5.6
accepted: see Breaking below, and mint's scope and ttl, which are
gone. It is also the first release built on GaugeWright's own fleet rather than
on GitHub Actions. The archives, installers and formula are the same set as
before; what changes is how a release is verified. A GitHub build attestation
is bound to an Actions run, so this release carries an in-toto provenance
statement over every file's SHA-256 instead, signed with the key published in
docs/release-provenance.pub, and an SPDX SBOM.

Added

  • whip issue cancel <id> [--reason R] and whip issue reopen <id> [--note N].
    cancel withdraws an open issue nobody will do and releases any claim on it
    in the same transaction, with finish's holder guard. A canceled issue is not
    a closed one: nothing waiting on the issue closing is woken by it. reopen
    returns a closed or canceled issue to open, and so to ready unless something
    blocks it. Both append the issue.canceled and issue.reopened events every
    store already folded, so a store written by 0.5.6 needs nothing.

  • whip issue set <id> status refuses a status outside open, closed,
    canceled and archived
    , as the compiler already did. cancelled used to be
    stored silently, as a status no rule could match.

  • mint credential from <parent> { … } (DR-0053 §5, as amended
    2026-08-27) — spend a credential at an issuer's token endpoint for a scoped
    child. The exchange is the author's, in the block form request established;
    the custodian executes it so the minted token never enters whip. Three
    refusals: an undeclared parent, an exchange presenting nothing, and an
    exchange presenting a different credential than the one minted from.

  • A minted credential can never reach further than the credential it was
    minted from
    (DR-0053 §5, as amended 2026-08-27).

    The custodian registers a mint as {parent}/mint-{fingerprint} and credential
    names are /-separated, so the egress ceiling now walks up the name. Nearest
    ancestor wins, so governance can narrow one mint further by naming it without
    restating the parent's list — and a governed-but-unscoped parent bounds its
    mints at nothing, rather than letting a child be the way around the ceiling.

    This is what mint is bounded by instead of a declared scope. scope and
    ttl are gone from CustodyOp::Mint: both were accepted and ignored
    (_scope, let _ = ttl_secs), both are vendor protocol, and both belong in
    the exchange body that actually goes on the wire. A clause beside the body
    duplicating it is the separate modifier §5 refuses for credentials — and the
    divergence was unresolvable, since §3 keeps the custodian from parsing the
    body, so a declared scope could never be checked against the exchanged one.

    whip therefore does not police which scope a mint requests — that string's
    meaning lives inside the vendor — and keeps the guarantee it can verify.

  • A credential's egress reach is bounded by governance (DR-0053 §14, as
    amended 2026-08-27).

    grant credential stripe_api -> credential:acme/stripe-live sealed at hardware
    grant request    stripe_api for POST https://api.stripe.com/v1/refunds/*
    

    §14 grounded scope narrowing in the turn grant, which attaches only to
    tell and invoke. The rule-body request — the one construct that reaches
    the custodian — had no list to consult, and an agent had no custody surface,
    so the turn clause parsed, passed its class check, and bound nothing.

    The ceiling now lives in the signed envelope, where it binds regardless of
    which construct uses the credential and no program text can widen it. It is
    in the canonical form, so the signature covers it. Refused at check time for
    a literal URL and at egress always. It applies once governance binds the
    credential; a policy that never mentions one does not constrain it.

    Matching is component-wise against a parsed URL: * never crosses from host
    into path, and userinfo cannot impersonate a host. A leading * must stand
    for a whole label — *.stripe.com is the subdomain wildcard, *stripe.com
    is refused because it reads as narrowed while admitting evil-stripe.com.

  • Agents can make authenticated requests, narrowed by their turn grant. A
    credential_request tool is offered only to a turn whose grant lists
    request on a credential, and enumerates exactly those credentials. A call
    is admitted only when the turn's globs and the envelope's scope both admit
    it — a turn narrows, never widens, the same way a file-store turn grant sits
    under the store's own allow globs. The material never enters the agent's
    process: the turn names a credential and the custodian substitutes at egress.

Changed

  • BREAKING — spend-table rates are whole micros of USD per Mtok.
    input_per_mtok_usd and its three siblings become input_micros_per_mtok,
    output_micros_per_mtok, cache_read_micros_per_mtok and
    cache_write_micros_per_mtok, each a non-negative whole number. $3.00/Mtok is
    3000000.

    The unit moved into the key name because the two readings differ by a factor
    of a million and nothing in a file says which one it means, so an old table is
    refused by name rather than silently priced at a millionth of itself. The
    message carries the replacement key and the conversion.

    Every published rate is exact in this unit, and cost_micros now accumulates
    the four buckets in u128 and rounds once, upward, rather than summing f64
    dollars and rounding at the end — so a turn costing a fraction of a micro
    costs one micro rather than nothing, and four buckets cannot each contribute
    their own error. A spend cap therefore binds no later than it was told to.

    This also makes the table the one rate document the estate shares: GaugeDesk
    prices WhippleScript's stats report from it rather than from a second table of
    its own.

  • A projection writes only the files that are not already right, and never
    holds the manifest in memory.
    materialize_manifest_subset loaded every
    body into a vector before writing any of them, so projecting a tree cost the
    sum of the tree — and the common case is that it had nothing to do at all,
    because commit_turn imports a worktree and then projects the branch back
    onto the same bytes. It now reads one and writes one, and
    materialize_manifest_onto takes a scan's own cache and skips every path that
    cache can vouch for, under exactly the rule scan_dir uses for the same
    question — size and mtime unchanged, and that mtime strictly older than the
    scan's stamp. Anything inside the racy granule is written, which is what
    every path got before. The byte budget is now answered from recorded sizes
    rather than by loading the closure to discover it does not fit.

  • Importing a worktree no longer reads its largest file twice. The scan
    hashes each file a window at a time rather than reading it whole, and
    ContentBlobs::put_file lets a store that can write incrementally do so —
    ContentStore writes past the 4 MiB threshold straight into the row through
    SQLite's incremental blob interface, verifying the bytes it writes against
    the id it keyed them under so a file moving mid-import is refused rather than
    stored under an id that does not describe it. Measured: importing an 80 MiB
    recording grew the resident peak by 82 MB where it grew by 161 MB before.
    Identity, representation below the threshold, and every other
    ContentBlobs implementation are unchanged — the seam has a default that
    reads the file, which is what every caller did before it existed.

  • A source span is no longer part of a program's identity (DR-0095).
    ir_hash is now stable_hash_hex of the .ir snapshot's identity
    projection
    — the same document with its source offsets erased. So a
    compiler change that only improves a diagnostic span rotates nothing
    , and
    ir_hash is stable under formatting changes outside a rule body.
    lowered_ir_report.accepted_program_digest goes through the same projection.
    The snapshot keeps its spans: to_snapshot is unchanged, all 25 .ir
    goldens are byte-identical, and a runtime event is still attributed back to
    source through them.

    Scope, stated because it is narrower than "formatting is free": a reformat
    still mints a new program version. A version row is
    UNIQUE(program_id, source_hash, ir_hash) and source_hash hashes the
    source TEXT, so whitespace mints a row through source_hash whatever
    ir_hash does. And a rule's body_hash is still a digest of its body TEXT,
    so a blank line inside a rule body still moves ir_hash — deliberately,
    because inside a """ prompt indentation is prose a model reads.

    This rotates ir_hash once, for every program that exists. It is the
    same cost that was already being paid silently on every span fix, paid once
    deliberately instead, and it lands on the mechanism built for it: a matching
    source_hash with a differing ir_hash is re-attested with an
    instance.program.reattested event, not refused. Which programs compile does
    not change.

Fixed

  • A mint exchange was invisible to the information-flow checker. It
    shipped two days after request and repeated that gap exactly: no
    resource_for_body arm, so the token exchange — an egress under the parent
    credential — had no sink and no payload reads. The parent is the sink
    identity, since the child does not exist yet.

    Found by making check_with_envelope's reader-set match exhaustive, on
    the model of DR-0074's collect_effect_binding_roots: every IrEffectKind
    is now named, several with an arm that does nothing and says why, so adding a
    variant is a compile error rather than a silent escape. Writing the mint
    arm and noticing it could never fire is what surfaced the missing resource.

    The exhaustive match is a backstop against the next hole, not a fix for the
    remaining ones: an arm only matters if the kind has an IFC resource, and
    seven kinds still have none, so their arms are written and unreachable. That
    is recorded on spec/flow-checker-resource-kind-tracker.md with the list,
    rather than left to read as more than it is.

  • A filed tracker issue was invisible to the information-flow checker.
    DR-0051 §1 gave trackers a read side — a when <tracker> has ready issue
    trigger keys the bare handle — and never a write side. So a rule could
    file issue into ops { body charge.note } with a confidential charge, and
    the checker reported nothing at all: a tracker item is a durable surface that
    humans and other agents read.

    A file issue is now a write sink keyed by the bare tracker handle, so both
    directions name the same resource, and its field values are recorded as what
    the payload reads. Mutation-verified, with a cleared tracker still compiling
    so the fix cannot degenerate into "deny every filed issue".

    Found by the same method as the request hole below, and on the same day:
    enumerating which effect kinds the reader-set classification actually names,
    rather than trusting that its _ => {} arm was fail-closed. It is not — an
    unnamed kind is neither a read nor a write. Thirteen of twenty-two kinds are
    still unnamed there; spec/flow-checker-resource-kind-tracker.md is reopened
    to carry that, along with finish item { summary … }, which names an item
    binding rather than a tracker and so has no statically-known sink.

    A confidential value filed into an unlabelled tracker is newly refused. The
    exits are the usual two: clear the sink, or declassify.

  • request was invisible to the information-flow checker.
    IrEffectKind::HttpRequest appeared nowhere in ifc.rs, and the parser
    returned no IFC resource for it. The construct that egresses a URL, headers,
    and a body to an arbitrary external host got no reader-set check, no
    denied flow, and no entry in the flow graph — a confidential value could be
    sent to an uncleared endpoint and nothing said so. It shipped that way in the
    same release that introduced it.

    A request now names its credential as its IFC resource and is classified as
    both a read and a write. That is the accurate reading rather than merely
    the conservative one: the payload leaves the process and the response comes
    back. Both directions refuse, and both refusals are mutation-verified.

    The sink identity is the credential handle, not the URL: it is the resource
    the program declares and the one governance grants by identity, so it is what
    an envelope can actually grant. Two requests under one credential to
    different hosts therefore share a sink.

    A governed program may newly be refused, and that is the fix working.
    Because a request is also a read, what comes back carries the credential's
    reader set: a rule that requests under a readable by Ops credential and
    completes a public result is now a denied flow. The join is per rule, as
    it is for a file store read, so the refusal does not ask whether the
    completed value derives from the response. The exits are the same as
    everywhere else — clear the sink, or declassify.

  • A credential reference now has to be one. credentials_ref (provider
    binding config) and credential_ref (signed host policy) were free strings
    that nothing parsed. A key pasted into either field validated clean and was
    then written into recorded validation evidence and a canonicalized, signed
    policy envelope — reference-not-value (DR-0053 §2) held by convention only.

    Both now parse. Every spelling the migration recognizes still validates, so
    nothing that was ever a reference is rejected; what is rejected is a value
    that names no scheme.

    The refusal does not echo what it refused, for the same reason: the input
    that reaches that arm is the one most likely to be the material, and an
    error string travels into diagnostics, provider reports, and evidence. A test
    in whipplescript-custody pins the no-echo property and both call sites
    assert it again.

  • The legacy credential path reports itself as degraded (DR-0053
    Migration). Every credential whip auth/coerce resolves for itself —
    OPENAI_API_KEY, a stored key, the Codex OAuth token — is material whip
    holds in its own process. That is r0, and it is not the same as an r0
    custodian entry, which seals at rest under a passphrase-derived key. Until
    now the two printed identically, so an operator deciding whether their setup
    meets require credential <rung> could not tell which one they were running.

    whip auth status now carries credential_ref, rung, and degraded in
    its JSON and a second line in its text output, and provider validation
    reports a legacy reference as credentials_ref_degraded rather than plain
    credentials_ref_available. Legacy still passes — the shim exists so
    existing setups keep working — it just says so.

    A rung is still never claimed from configuration: a credential:<name>
    reference reports no rung at all, because only the custodian's derived
    evidence may state one
    (models/maude/credential-rung-evidence.maude).

  • The custodian redacts its own material out of an egress response. An
    endpoint that echoes the credential back — an auth-debug route, a
    misconfigured mirror — returned material that then landed in whip's run
    record, having arrived from outside.

    whip cannot fix that: it is designed never to know the material, so it cannot
    recognise it to redact it. The custodian holds both the material and the
    response, so it now redacts on the way back. It records exactly what it put on
    the wire — the presented string, the material as text, and its base64, so a
    basic credential and a bare-token echo are both caught — and replaces those
    in the response headers and textual body, longest fragment first.

    mint's exchange deliberately does not scrub: that response is parsed for the
    minted token and never handed back, and scrubbing could corrupt a token
    containing the parent's text.

    Substring redaction only. A response that transforms the credential —
    hashing it, returning a prefix — is not caught, and a binary body passes
    through. This is defence in depth behind the type system, not a second
    guarantee.

Added

  • obtain credential — non-blocking governance escalation (DR-0053 §11).

    obtain credential deploy_key into ops {
      title "deploy_key is not granted"
      body "Deploy blocked: {{ blocked.reason }}"
    } as escalation
    

    A rule that discovers it needs authority it was not granted files a tracker
    item and derives a credential.requested fact. Nothing waits: the run
    proceeds, or fails on the authority it still does not have, and a later run —
    after a human edited governance — succeeds. A blocking request would be the
    shape the language removed with ask_human.

    The fact is what makes it an escalation rather than a notification. It
    carries the credential, the tracker, and the item id, so a rule matching
    when fact credential.requested as asked acts on the program's own missing
    authority without re-reading the tracker.

    The tracker is named in the statement, as every tracker write in the language
    is; there is no ambient escalation queue. A credential the program does not
    declare is a check error — the escalation would ask a human for authority no
    rule could use, and would look answered while changing nothing.

  • secret carries its credential kind (DR-0053 §15).

    class ReleaseKeys {
      signing secret<ed25519>
      webhook secret<hmac_sha256>
      anything secret
    }
    

    The checks that depend on a credential's kind are keyed by its name, and
    each of the four things §5 designs a secret to do — bind, pass, store in a
    record field, sit in an effect position — leaves no name to resolve. The
    discriminant is what a stored secret still carries.

    Bare secret stays valid and means "any kind", so there is no source break.
    A kind outside the protocol's closed set is a check error rather than a
    silent widening: widening would hand the author a narrowing they asked for
    and did not get.

    The angle brackets are a built-in constructor, as in map<string>, not a
    type parameter — the argument ranges over a closed set of values, not over
    types. Built now because §15 said now: request has landed but sentinel
    lowering into value slots has not, so nothing yet produces a secret value in
    an expression. The use-site check that rejects secret<bearer> where
    secret<ed25519> is required arrives with those values.

  • request — authenticated outbound HTTP (DR-0053 §5).

    credential stripe_api { kind bearer }
    
    request POST "https://api.stripe.com/v1/refunds" {
      header "Authorization" bearer stripe_api
      header "Idempotency-Key" ticket.id
      body ticket.id
    } as refund
    

    whip builds the request with sentinels at the marked slots and never with
    material; the custodian substitutes and signs at egress. The request whip
    constructs and records carries a handle, not bytes. Presentation forms are
    bearer, basic, and raw; a handle in a slot is not an expression and
    never reaches the expression checker, because no expression yields material.

    Three compile-time refusals: an undeclared handle, a request that presents
    nothing and signs nothing (signed with alone satisfies it — signing is
    authentication), and more than one distinct credential in one request, since
    one custody operation carries one credential's material.

    An HTTP error status settles as a completed effect carrying the status —
    the endpoint was reached. after … fails means whip could not make the call,
    which includes having no custodian configured: that fails loudly rather than
    egressing unauthenticated.

    Spelled call in DR-0053 until the 2026-08-25 amendment; call was already
    package capability invocation.

    Known exposure, not solved here. An endpoint that echoes the credential
    back returns material that lands in the run record. whip cannot redact it —
    it is designed never to know it — so only the custodian can, and it does not
    yet. Recorded on the credential-custody tracker.

Breaking

  • A grant on a resource your program does not declare is now classified as
    both a read and an egress
    (DR-0072).

    The static flow checker classified a grant by its operation verb against two
    closed lists. An operation in neither list contributed nothing, so
    with access to github { get_issue } on a Secret-labelled server, writing to
    a public store, drew no diagnostic — while the same grant spelled
    github { read } was denied. Worse than the silence: a server whose tool
    happened to be named get was classified read-only, a confident answer
    derived from a naming accident in a name the server chooses.

    The verb vocabulary now applies exactly to resources the program declares — a
    file store, tracker, ledger, channel, memory pool, counter, lease, or
    stream. Every other resource is foreign, and every grant on one is both
    directions regardless of naming. An unknown operation on a declared resource
    is also both: the checker does not guess.

    This is accurate, not merely conservative. A remote tool call ships its
    arguments and returns a result, so it genuinely moves data both ways. The old
    behaviour was not cautious-but-imprecise; it was wrong in the permissive
    direction.

    A program whose foreign-resource grant carries a real unchecked flow now fails
    to build. It was always leaking. Across the workspace suite, 2446 tests pass
    and 0 fail under the rule, and the shipped examples are unaffected because
    they grant on declared file stores. MCP and the web { search fetch } grant
    closed together, as they had to — the hole predated MCP.

    What is unchanged: the checker classifies a grant, not the journey of a
    value into a particular tool argument.

Added

  • Tracker-event subscriptions, delivered mid-turn. An agent can watch a
    tracker queue and learn that another actor claimed or closed an item while it
    is still deciding, instead of discovering the collision when their changes
    meet. Claim is the load-bearing event: an open/closed pair only tells you
    after the wasted work is done.

    Two ways to subscribe, and they write the same durable subscription. An
    embedder names the queues a turn watches with
    WHIPPLESCRIPT_HARNESS_TRACKER_FEED (comma-separated); the agent can narrow
    or widen that with the subscribe_todos tool, which is governed by its own
    grant — with access to tracker { subscribe } (or watch). That grant is
    deliberately not implied by write or update: subscribing is a read, and
    folding it into a write grant would hand every writer a feed it never asked
    for.

    Notices arrive as prose (WS-12 (title) was claimed by agent:bob) framed as
    information rather than instruction, matching the existing raise notice —
    mid-turn delivery is another principal's content entering a model's context,
    and a line that read like a directive would be one an attacker could author
    by filing an issue. The rendered event carries the alias, kind, actor, and
    title, and never the event payload or issue body, so nothing accumulated
    there can reach a subscriber through this channel.

    The subscribe grant does not widen which queues a turn can read: an agent
    may name only its own configured queue and the queues the host declared, and
    any other queue is refused. list_todos is scoped to the configured queue, so
    without that confinement the grant would have let an agent watch another
    agent's queue and read its titles, aliases, and actors — a strictly wider read
    than it could perform directly.

    The cursor is a durable per-(subscriber, queue) watermark over the local
    event sequence. Subscribing starts at the current head rather than replaying
    a queue's history; re-subscribing never rewinds; a stale advance is a no-op.

    Delivery is owned-harness-only, as DR-0052 has it — coordination granularity
    is a property of the harness, and on the durable object the turn boundary
    remains the atom. The durable-object store implements subscriptions at full
    parity regardless, since both hosts share one schema.

Fixed

  • A non-holder can no longer release or close a claimed tracker item
    (tracker-lease.maude I4). update_todo called finish_item and
    release_item with no holder, and WorkItems::release_item took no holder at
    all — it stripped whichever active lease it found. With several agents on one
    tracker, a stale agent could unclaim or close work another agent was still
    doing, and both would then proceed believing they owned the item.

    release_item and finish_item now take expect_holder: Option<&str> and
    return ReleaseOutcome / FinishOutcome instead of bool. Some(actor)
    refuses with HeldByOther { holder } when a different actor holds the lease,
    and both agent paths pass it — native and durable object alike, because a
    refusal enforced on one host is one an agent evades by running on the other.
    None preserves the unconditional behaviour for the operator escape hatch
    (whip issue release, whip issue fail) and the in-program release effect,
    so a stuck lease stays clearable.

    The check runs inside the mutation's own transaction; a caller-side
    read-then-act would be a TOCTOU race against the CAS the lease exists to be.
    It guards against clobbering another actor, not against acting on an
    unclaimed item, so closing an unclaimed item still works.

    The lease model had no rule for explicit release at all before this — only
    terminal-release (I3) — so the implementation was less holder-aware than its
    own model. I4 generalizes I2's holder-only renew to any holder-scoped lease
    mutation.

    Breaking for embedders implementing WorkItems or calling either method:
    both signatures gained a parameter and both return types changed.

Verifying this release

Built on GaugeWright's own fleet. Every file's SHA-256 is in
whipplescript-0.6.0.intoto.json, signed with the key in
docs/release-provenance.pub:

minisign -V -p release-provenance.pub -m whipplescript-0.6.0.intoto.json