Repository navigation
capsulectl v0.1.0-rc10
Pre-releasecapsulectl 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
fyicapsule 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-v0extension 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 reportthat 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
fyicapsule (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. |
bundlestill writes the bundle (--out, or stdout) when its page is refused.disclosewrites 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_digestpins 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/v0object (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 underbody.unchecked.history_scope:{days, max_records, complete}.
- Output: exit 0 and one
external-check-result/v0object (skills/deal/profile/external-check-result-v0.schema.json).ruleset_id,definition_digest;verdict:allow,deny,escalateornot_evaluable;- an optional
tier, as in Result v0:recomputedorjudged. An absent tier reads as judged, never recomputed; - an optional
grade, by reference to Result v0's Grade:self-attested,witnessedorcountersigned; 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-v0verdict gainsresult: "deny", a deny offering onlyhold. It also gainsrules, holding:status:evaluated,not_evaluatedornot_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), itsgrade(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
causetoken (checker_unavailable,checker_changed,refused,timeout,unreadableor
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/v0gains dispositionDENYandrules_checks({ruleset_id, definition_digest, verdict}), andcheck-response/v0gainsDENY.ruleset_digeststill 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_minutesand 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 NAMErenames it toNAME-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_DIRalso 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 checkfor a pay on such a rail must passauthorized_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_atandstale_after_minutesfor staleness. - Anything that branches on a check's verdict: handle
deny(and typedDENY), 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 withprofile update --rules-checker FILE.
New
profile retire NAME(see Migration).profile update --rules-checker FILE(see "deal checkruns the user's pinned rules checker").- The external-check contract:
external-check-input/v0andexternal-check-result/v0, published beside the deal profile. verify --capsulereads a bare capsule as capsule-emit seals it: the capsule's own fields, with an inlinesignatureand
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 "
discloseseals 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 FILEbeside--key-record FILE. - The deal skill tells the agent to offer this way on rather than stop.
- The error names the record and the way on: retry, or get that TXT record another way and pass it with
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 thecomposed/v1verifier.github.com/action-state-group/capsule-emit-gov0.2.0,github.com/action-state-group/evidencebookv0.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