Skip to content

capsulectl v0.1.0-rc10

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 08 Oct 08:12
· 84 commits to main since this release
e3f4656

capsulectl v0.1.0-rc10

Behaviour and wire changes

A deal receipt's text is sealed with it

Before: a deal receipt's readable text (the summary lines, each step's line, the amounts, what was told and the anomalies)
was carried in the bundle's x-deal-v0 extension, which no record seals. Editing that text left verify --bundle VALID, and the
receipt page showed the edited text.

Now: writing a receipt seals that copy's own text as a record. That covers deal report --html, --bundle and --email,
bundle --deal and disclose --deal.

Wire change: one sealed report record per receipt written.

  • The record: an fyi capsule on the deal's own log (action_id: "capsulectl-deal-report"), not chained to the steps.
  • Its input: {"type": "deal_report", "profile": "x-deal-v0", "deal_id", "audience", "nonce", "report"}. The nonce is a fresh
    random 256-bit value, so the record's digest confirms no guess at the text.
  • The bundle: its x-deal-v0 extension is now {"sealed_report": "<capsule id>"}. The text is that record's disclosed input,
    checked like every other disclosure.
  • One record per receipt: each copy seals its own text. A shared copy seals the text already rewritten for its audience, never
    the user's own.
  • Later copies: they carry every earlier report's record with its input withheld. A copy discloses its own text and no other
    copy's.
  • No file, no record: a deal report that writes no file seals nothing.

What verify --bundle reports for x-deal-v0:

The bundle Result
Names its sealed report, whose disclosed text matches pass
Text edited, or the extension names another record INVALID, sealed_report_unverified
Holds a sealed report record but the extension names none: removed, or replaced with text, even with the record's input dropped INVALID, sealed_report_not_named
A receipt written before this release (no report record; the text in the extension) VALID as before: uninterpreted, with the finding extension_unbound and a note that its text is not verified

The page:

  • A sealed copy says it checked that the text has not changed since it was sealed, and that the text is capsulectl's summary of
    the steps, not the steps themselves.
  • An edited or downgraded copy shows no deal text.
  • An older receipt rendered with this release says its text is not checked.

The deal's log now holds report records after its steps:

  • Reading a deal sets them aside.
  • Witness coverage is counted in steps: a tick whose checkpoint holds every step, with only report records after it, still reads as
    witnessed, or as pending for its stated reason.

Records declare their commitments' construction (commit_alg)

Wire change (additive). Every deal record sealed from this release carries commit_alg: "sha256-jcs-nonce256": in the
x-deal-v0 block, or in a typed record's header. It names the construction of the record's *_commitment values:

  • SHA-256 over the RFC 8785 (JCS) bytes of {"nonce", "text"};
  • a fresh 256-bit random nonce for each commitment, and no key;
  • recomputable only by a holder of the opening (the nonce and the text).

Older records and shared copies: records sealed before carry none and re-derive unchanged. commit_alg is on the share
allow-list, so shared copies still disclose the records that carry it. PROFILE.md section 4 states the construction, its nonce and
key scope, and where openings travel (only the user's own copy carries any).

disclose seals each disclosure as a capsule

Wire change: what disclose puts on the log. It used to append the bare digest of a disclosure record that was kept nowhere
else. Once a checkpoint covered such a digest, every later bundle and disclose on that profile failed ("operation failed …
sensitive details suppressed").

  • Now: it seals the disclosure record as an ordinary fyi capsule (action_id: "capsulectl-disclosure") with the profile's
    signing key, stores it, and appends its capsule id.
  • The record's input: ids, digests and labels only, never disclosed payload bytes, plus a random 256-bit nonce.
  • A later bundle proves the earlier disclosure's place in the log but withholds its input (payloads_mode: selected), so a copy
    made for one party never shows what was disclosed to another.

A page is written only when it can stand behind what it shows

bundle --html, disclose --html and deal report --html write a page only for a bundle that verify --bundle calls VALID.
Before, the page's viewer said "Bundle verification passed" for a bundle the CLI calls INCOMPLETE.

The bundle What happens
INCOMPLETE or INVALID No page. The command exits as verify --bundle would (3 or 1) and names the claims that did not pass.
A report/v1 root whose rows cite records the bundle does not carry No page, exit 3, naming how many rows it would show ("would show 1 of 2 rows").
From a jsonl profile No page, exit 2. Its bundle carries its evidence book's records about what was sealed, not the records themselves, so a page cannot show them. Publish on a SQLite profile for a page.
  • bundle still writes the bundle (--out, or stdout) when its page is refused.
  • disclose writes nothing and puts no disclosure on record.
  • Unchanged: every page path measured on rc9 was VALID: deal receipts, own and shared, and the README's self-checking report.

A spend limit binds the most a payment may take

The gap: a pay check carried only the expected charge, so an authorization buffer evaded every limit. For example, a $4.54
order passed a $5 limit while the card approval let the merchant take up to $9.54.

The new field: a pay check takes authorized_max_minor, the most the counterparty may take under the payment's authorization
(a card hold, or a pre-authorization with a buffer). It must be at least amount_minor, which stays the expected charge. The intent
limit evaluates it, and a difference names it.

Required for card, wallet and PayPal pays (fail safe): a pay by card, wallet, PayPal, or any rail not known to be holdless is
refused (exit 2) unless it states authorized_max_minor. Give the same value as amount_minor when no larger maximum is shown. A
pay on a holdless rail (bank transfer, Zelle, wire, cash and the like) defaults it to the amount.

Sealed (additive): the check carries authorized_max_minor, and spend_authorized_minor beside spend_minor. A typed
proposed-action/v0 carries it too, so an approval never extends to a larger maximum.

  • The capture: an action that takes more than the authorized maximum differs from its check.
  • Older records: records already sealed re-derive unchanged.

deal check runs the user's pinned rules checker, and seals what it said

A deal profile may now pin an external rules checker. Every deal check runs it and folds its answer into the check's one verdict
and one prompt. capsulectl knows no ruleset of its own: a ruleset id appears in a check only because the pinned checker reported
it.

Pinning (the user's policy, never the agent's): profile update --rules-checker FILE, where FILE is
{"command": ["/absolute/path", "arg", …], "timeout": "10s", "definition_digest": "<64 hex>"}.

  • Where the executable lives: it must sit under a trusted plugin root, and is pinned by its SHA-256.
  • Re-checked every time: every check re-reads the executable once, re-hashes it, and runs a private copy of exactly those
    bytes. A file swapped after the hash is not what runs.
  • Optional: definition_digest pins the ruleset the checker must report.
  • Timeout: 10s by default, at most 60s.

The contract (neutral, versioned v0; checkers vendor these files byte for byte):

  • Input: stdin is one external-check-input/v0 object (skills/deal/profile/external-check-input-v0.schema.json).
    • record: the sealed capsule of the step being checked, with its disclosed record.
    • history: the profile's sealed acts with an amount, from every deal on the profile's own store, sealed in the last 31 days,
      newest first, at most 1,000, in the same shape. An act taken without a check carries its fields under body.unchecked.
    • history_scope: {days, max_records, complete}.
  • Output: exit 0 and one external-check-result/v0 object (skills/deal/profile/external-check-result-v0.schema.json).
    • ruleset_id, definition_digest;
    • verdict: allow, deny, escalate or not_evaluable;
    • an optional tier, as in Result v0: recomputed or judged. An absent tier reads as judged, never recomputed;
    • an optional grade, by reference to Result v0's Grade: self-attested, witnessed or countersigned;
    • findings[]: {id, check, verdict, reason, limit, value}, passes included. Each limit and value is a number, or a string of
      at most 64 characters with no line break.
    • Anything outside the schema is refused.
  • Refusal: any other exit refuses the input, with the cause on stderr.
  • Rolling windows: a rolling window is evaluated over the history supplied. A checker given too little reports the rule
    not_evaluable.

One verdict:

The checker deal check
allow adds nothing
escalate or not_evaluable pauses, naming each failing rule with its limit and value ("Your rules ask for approval: …", "Your rules were not fully checked: …")
deny the new verdict deny: the card names the rule, its limit and the value, and offers only "Hold"; an approval to proceed is refused
configured but changed since pinned, refused the input, timed out, printed anything else, or reported another ruleset digest pauses: "Your rules were not checked: "
none configured does not pause; the text says "Your rules were not checked: no rules checker configured."

Wire changes (additive).

  • The x-deal-v0 verdict gains result: "deny", a deny offering only hold. It also gains rules, holding:
    • status: evaluated, not_evaluated or not_configured;
    • for an evaluated check: the ruleset id and definition digest, the checker's SHA-256, its verdict, its tier (as reported, or
      not_stated), its grade (as reported, or omitted), each finding as data ({id, check, verdict, limit, value}), and
      history ({days, acts, complete}: what the checker was given);
    • for a check not evaluated: a cause token (checker_unavailable, checker_changed, refused, timeout, unreadable or
      ruleset_changed).
  • No words the checker wrote are sealed in the clear. Its reasons and its stderr reach the card, which the approval commits to.
  • Typed records: action-evaluation/v0 gains disposition DENY and rules_checks ({ruleset_id, definition_digest, verdict}), and check-response/v0 gains DENY. ruleset_digest still covers only the built-in rule table.
  • Shared copies carry none of the user's rules: a verdict with evaluated rules is withheld from them, and rules findings read
    in fixed words ("Your own rules did not allow this", and so on). The user's own copy carries the ruleset id, digest and verdict.
  • Reports: a denied check reads "Checked before paying: not allowed by your rules", and rules findings are listed on the
    agent's side.

The approval text changed.

  • The first line says whether the rules were checked, by which ruleset, and how the verdict was reached ("Your rules
    (, digest <8 hex>): allowed, computed by your rules, self-attested." / "…, judged (not stated)." / "Your rules were not checked: …").
  • The second line: the action line follows it, then "Before you go ahead:" in place of "Deal check:".
  • The stale line: "Checked at … Stale after N minutes …" has left the text. Staleness stays in checked_at,
    stale_after_minutes and the sealed record.
  • "Pay anyway" (or "Confirm anyway", and so on) is offered only over a real finding. A check paused only because the rules
    could not be checked offers "Pay".

verify --bundle checks composed/v1

A bundle carrying a composed/v1 extension (Evidence Bundle -01 §7.2) is now verified, not listed as uninterpreted:

  • the composed digest;
  • each member, with a full assessment of each carried member bundle;
  • composition closure, joins and the redundancy flag.

Its effect on the verdict: a failed block or an INVALID member bundle makes the bundle INVALID. A declared-missing member, a join
that cannot be derived, or an unverified refusal signature makes it INCOMPLETE. Other extensions are still listed as carried but not
checked.

Migration

  • A rc7 or rc8 jsonl profile: its bundles are INCOMPLETE and it writes no page (see above). To keep its name for a new SQLite
    profile, retire it:
    • capsulectl profile retire NAME renames it to NAME-retired-YYYYMMDD, with its log id, keys and data directory unchanged.
      Every record, checkpoint and witness receipt stays in its own log, and the retired profile still bundles and verifies them.
      Nothing is deleted, moved or re-logged.
    • The retired profile is not made read-only: a jsonl profile's bundle puts itself on record.
    • --move-data NEW_DIR also renames a jsonl profile's data directory, to a new path on the same filesystem. It refuses a SQLite
      profile, an existing path, a key file inside the data directory, and a store a running command has open.
    • Anything that runs capsulectl with --profile NAME (a scheduled checkpoint cadence, a plugin) is not changed; point it at the
      new profile or at the retired name.
  • A log on which an earlier release disclosed and then checkpointed: it cannot be bundled over, because the record behind a bare
    disclosure digest is gone. The error now says what it can know, exit 2: "log entry N is checkpointed but not in this profile's
    store: either a disclosure an earlier release appended as a bare digest, or a lost record; no bundle over this log can include it;
    start a new profile (or log id) for new reports".
  • Card, wallet and PayPal pays: agents calling deal check for a pay on such a rail must pass authorized_max_minor. The deal
    skill tells the agent to read it from the approval or card hold.
  • Anything that reads approval_text: it now leads with the rules line and no longer carries the stale line. Read
    checked_at and stale_after_minutes for staleness.
  • Anything that branches on a check's verdict: handle deny (and typed DENY), which offers no way to proceed.
  • Profiles with no rules checker: they keep working with no pause, and every check now says the rules were not checked. To
    check them, pin a checker with profile update --rules-checker FILE.

New

  • profile retire NAME (see Migration).
  • profile update --rules-checker FILE (see "deal check runs the user's pinned rules checker").
  • The external-check contract: external-check-input/v0 and external-check-result/v0, published beside the deal profile.
  • verify --capsule reads a bare capsule as capsule-emit seals it: the capsule's own fields, with an inline signature and
    key_id. The checks are the same as for the artifact.Record wrapper. The file's shape is read from its members, never guessed,
    and named in the output (shape). A file with the members of both shapes, or of neither, is refused.

Fixes

  • A second report on a profile works after a checkpoint covers a disclosure (see "disclose seals each disclosure as a
    capsule").
  • A merchant email whose key record DNS could not reach:
    • The error names the record and the way on: retry, or get that TXT record another way and pass it with --key-record FILE. It
      is sealed as supplied (key_source: supplied), which is weaker evidence than a record read from DNS.
    • A DMARC record that could not be read names --dmarc-record FILE beside --key-record FILE.
    • The deal skill tells the agent to offer this way on rather than stop.

A deal receipt says where the deal stands, and whether the merchant's receipt matches

Consumer wording on the receipt page and in the emailed receipt. No record type, field or verb changes. "receipt" is never a field,
record type or key: a test keeps every deal and external-check schema free of one.

  • An open deal with no merchant email yet: "Open: waiting for the merchant's receipt." With the merchant's email sealed:
    "Open: the merchant's email is sealed; no close is sealed on this deal yet." Both are followed by the expected close date.
  • A completed close: "Closed at …: matched, nothing left to match." Other outcomes are unchanged.
  • A merchant email sealed after the close: "The merchant's receipt arrived. It matches what you approved." or "The merchant's
    receipt arrived. It differs: you approved $48.00; their receipt says $64.00." This needs an approved amount on record; a copy the
    merchant's signature does not confirm says so. Shared copies keep their fixed words.
  • The user's choice on a check reads as it was shown: "you chose Pay anyway" over a finding, "you chose Hold".

Deal skill

  • The approval text: say "Your rules were not checked: …" exactly as given, and never say the rules ran when the text does not
    name them.
  • A deny: do not act, and do not look for a way around it.
  • Approved wording for the merchant's confirmation:
    • an open deal waiting on it: "waiting for the merchant's receipt";
    • at the close: "matched" or "nothing left to match";
    • a late email: "The merchant's receipt arrived. It matches what you approved." or "… It differs: you approved $4.54; their
      receipt says $5.20."

Dependencies

  • github.com/action-state-group/agent-action-capsule/go: v0.6.1-0.20261007011327-65b3fdc8f358 (untagged) → v0.7.0, which
    carries the composed/v1 verifier.
  • github.com/action-state-group/capsule-emit-go v0.2.0, github.com/action-state-group/evidencebook v0.1.1: unchanged.

Verify

Prebuilt capsulectl binaries (linux/amd64, linux/arm64, darwin/arm64).

  • Checksums: sha256sum -c SHA256SUMS.
  • Build attestation, with no GitHub account:
    gh attestation verify FILE --bundle capsulectl-v0.1.0-rc10.sigstore.json --repo action-state-group/capsule-cli --signer-workflow action-state-group/capsule-cli/.github/workflows/release.yml