0.6.0 — 2026-09-24
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]andwhip issue reopen <id> [--note N].
cancelwithdraws an open issue nobody will do and releases any claim on it
in the same transaction, withfinish'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 theissue.canceledandissue.reopenedevents every
store already folded, so a store written by 0.5.6 needs nothing. -
whip issue set <id> statusrefuses a status outside open, closed,
canceled and archived, as the compiler already did.cancelledused 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 formrequestestablished;
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
mintis bounded by instead of a declared scope.scopeand
ttlare gone fromCustodyOp::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
tellandinvoke. The rule-bodyrequest— 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.comis the subdomain wildcard,*stripe.com
is refused because it reads as narrowed while admittingevil-stripe.com. -
Agents can make authenticated requests, narrowed by their turn grant. A
credential_requesttool is offered only to a turn whose grant lists
requeston 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 ownallowglobs. 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_usdand its three siblings becomeinput_micros_per_mtok,
output_micros_per_mtok,cache_read_micros_per_mtokand
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_microsnow accumulates
the four buckets inu128and rounds once, upward, rather than summingf64
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_subsetloaded 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,
becausecommit_turnimports a worktree and then projects the branch back
onto the same bytes. It now reads one and writes one, and
materialize_manifest_ontotakes a scan's own cache and skips every path that
cache can vouch for, under exactly the rulescan_diruses 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_filelets a store that can write incrementally do so —
ContentStorewrites 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
ContentBlobsimplementation 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_hashis nowstable_hash_hexof the.irsnapshot'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_hashis stable under formatting changes outside a rule body.
lowered_ir_report.accepted_program_digestgoes through the same projection.
The snapshot keeps its spans:to_snapshotis 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)andsource_hashhashes the
source TEXT, so whitespace mints a row throughsource_hashwhatever
ir_hashdoes. And a rule'sbody_hashis still a digest of its body TEXT,
so a blank line inside a rule body still movesir_hash— deliberately,
because inside a"""prompt indentation is prose a model reads.This rotates
ir_hashonce, 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_hashwith a differingir_hashis re-attested with an
instance.program.reattestedevent, not refused. Which programs compile does
not change.
Fixed
-
A
mintexchange was invisible to the information-flow checker. It
shipped two days afterrequestand repeated that gap exactly: no
resource_for_bodyarm, 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'scollect_effect_binding_roots: everyIrEffectKind
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 themint
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 onspec/flow-checker-resource-kind-tracker.mdwith 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 — awhen <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 confidentialcharge, and
the checker reported nothing at all: a tracker item is a durable surface that
humans and other agents read.A
file issueis 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
requesthole 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.mdis reopened
to carry that, along withfinish 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. -
requestwas invisible to the information-flow checker.
IrEffectKind::HttpRequestappeared nowhere inifc.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
requestnow 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 arequestis also a read, what comes back carries the credential's
reader set: a rule that requests under areadable by Opscredential and
completes a publicresultis now adenied flow. The join is per rule, as
it is for afile storeread, 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) andcredential_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
inwhipplescript-custodypins the no-echo property and both call sites
assert it again. -
The legacy credential path reports itself as degraded (DR-0053
Migration). Every credentialwhip auth/coerceresolves 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
meetsrequire credential <rung>could not tell which one they were running.whip auth statusnow carriescredential_ref,rung, anddegradedin
its JSON and a second line in its text output, and provider validation
reports a legacy reference ascredentials_ref_degradedrather 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
basiccredential 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 escalationA rule that discovers it needs authority it was not granted files a tracker
item and derives acredential.requestedfact. 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 withask_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 askedacts 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. -
secretcarries 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
secretstays 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:requesthas landed but sentinel
lowering into value slots has not, so nothing yet produces a secret value in
an expression. The use-site check that rejectssecret<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 refundwhip 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, andraw; 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 withalone 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 … failsmeans whip could not make the call,
which includes having no custodian configured: that fails loudly rather than
egressing unauthenticated.Spelled
callin DR-0053 until the 2026-08-25 amendment;callwas 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 aSecret-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 namedgetwas 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 theweb { 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 thesubscribe_todostool, which is governed by its own
grant —with access to tracker { subscribe }(orwatch). That grant is
deliberately not implied bywriteorupdate: 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
subscribegrant 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_todosis 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.maudeI4).update_todocalledfinish_itemand
release_itemwith no holder, andWorkItems::release_itemtook 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_itemandfinish_itemnow takeexpect_holder: Option<&str>and
returnReleaseOutcome/FinishOutcomeinstead ofbool.Some(actor)
refuses withHeldByOther { 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.
Nonepreserves the unconditional behaviour for the operator escape hatch
(whip issue release,whip issue fail) and the in-programreleaseeffect,
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
WorkItemsor 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