Proposal: bind the authorization decision to what happened next (outcome events, schema v2) #8
Replies: 2 comments 1 reply
|
Read sections 3 and 5 against what the wrapper can actually know. Most of the epistemic traps I went looking for are already handled, especially refusing Five things remain. 1. For If it means the producer's record is internally consistent, 2. Parameter-binding coverage disappears at the aggregate. Section 4 can omit both parameter hashes, and section 5 honestly reports I would keep lifecycle status and binding coverage as separate axes rather than let one distort the other, and surface the coverage beside the aggregate: 3. The post-commit retry rule carries a correctness assumption the evidence may not expose. After If a retry receives a fresh 4. Keep observation capability and observer provenance separate when section 8 lands.
5. One boundary in section 5 should be explicit: does verification ever re-evaluate authority state? My preference is no. The committed allow record is the historical authority fact for this layer, and a revocation that happens later must not retroactively turn that chain into a contradiction. If that is already the model, one sentence saying the verifier does not consult current authority state would close it. If authority is re-evaluated anywhere, the reference time needs naming. The narrowed scope is good. The sentences I would defend hardest are that these are observation records rather than claims about what happened in the world, and that they are the producer's books rather than a witness. That is what keeps the rest of the format honest. On mapping the vocabulary into ours, I am leaving that for a separate decision rather than smuggling it into this review. |
|
Shipped: 0.9.0 (PyPI) and 0.4.0 (npm) implement this proposal as written, including the review amendments recorded above. Opt-in per chain; v1 untouched. The language-neutral params_c14n_v1 vector file ships in the Python package and the TypeScript suite verifies against the same bytes. The deferrals in section 8 stay deferred: runtime duplicate prevention, verifier capture policy, and the observer envelope, which will be designed with the two implementers who engaged on it in the A2A thread. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Scope, first. Earlier revisions tried to specify, in one page, an evidence layer, a runtime
prevention mechanism, a verifier policy language and an observer protocol. Four adversarial review
rounds later, this document specifies one thing: an evidence record that binds each authorization to
what the adapter's wrapper observed afterwards, and the offline checks over those records. Three things
are out, named in section 8 with where they will be specified instead: runtime duplicate prevention,
verifier-side capture policy, and the observer attestation envelope.
The claim shrinks accordingly. This layer does not prevent anything at runtime and does not prove what
happened in the world. It makes the producer's own account of "allowed, and then what" internally
checkable, offline, by anyone holding the bundle and an independently retained anchor.
1. Identity:
call_idGuard.check()allocatescall_idinside its locked transition: 16 bytes from the OS CSPRNG, lowercasehex. It is written on the
allowordenyentry and returned on theDecision. If the random sourcefails, the call is denied and nothing is written.
call_idis not derived from ledger content, so itsurvives redacting export unchanged. A repeated
call_idacross any twoallowordenyrecords in achain is
duplicate_call_idat verification. Only anallowenters the pending set; adenyneverexpects an outcome. An outcome must match exactly one
allow, never a deny. Ledger hashes remainintegrity links, never record identity.
The transition, in order under one lock: refuse if the node is finalized; evaluate authority and
ceilings, update meters; allocate
call_id; commit the entry to the in-memory chain; register anallowed call as pending; return the
Decision. A failure before the commit point: meters are restored,nothing is pending, the call is denied. After the commit point, the audit-path file write and every
configured sink are post-commit persistence operations: a failure there raises
CommittedAuditError,which carries the committed
entryand thedecision, and the guard registers an allowed call aspending before raising. Callers MUST NOT retry the body merely because this exception was raised. A caller that retries anyway
authorizes a new call and receives a fresh
call_id; the two records carry no statement that they wereattempts at one logical operation, and duplicate execution after such a retry is among the things
verification cannot establish.
call_idreuse across attempts is never permitted.Restart rule. A process restart cannot resume an existing chain. It must create a new chain;
pending calls in the old chain remain unaccounted, which is the truthful record. Opening an existing
audit path for a new chain fails without truncating it.
complete()returnsCompletionResult(completed: bool, pending_call_ids: tuple[str, ...]): it refuseswhile the pending set is non-empty and appends nothing.
killdoes not write outcomes: it revokes andrecords the pending ids as
pending_at_kill. A wrapper that later observes its body finish appends thereal record, accepted after the kill. A call that never reports stays exactly that.
2. What the
allowcarriescall_id.capture: what the adapter's code path will observe for this call, one ofwrapper_sync·wrapper_async·framework_post_hook·pre_hook_only, together withadapter: module name, versionand hook path. Capture describes observation capability, nothing more; when the observer envelope of section 8 lands,
observer identity gets its own field, and
capturenever acquires that second meaning.authorized_params_hash, andparams_hash_reasonon this record when its hash is absent (section 4).3. The
outcomerecordAppended by the capture point named on the allow, when its observation of the call ends. It is an
observation record, not a judgment about the world:
eventoutcomecall_idbody_statereturned·raised·abandoned·deferredinvoked_params_hashparams_hash_reasonon this record when absenterror_codebody_stateisraised. Never a messageduration_msreceiptreturned: the body returned to the wrapper.raised: it raised; a deadline the wrapper itselfenforced and turned into an exception is
raisedwith its code.abandoned: the wrapper stoppedobserving while the body may still run, which is what an external cancellation or an unenforceable
timeout actually is.
deferred: the wrapper returned a generator, stream or future whose consumption itdoes not observe; the record covers the call, not the eventual exhaustion. There is no
executed,blocked,timeoutorcancelledat this layer, because each of those words claims knowledge awrapper does not always have. Adapters emitting into outcome vocabularies own that mapping (section 6).
Exactly one outcome per
call_id, enforced at append under the lock within the chain's lifetime; therestart rule in section 1 is what makes that enforceable. A second outcome reaching a bundle anyway is
duplicate_outcome.4. Arguments: two commitments
authorized_paramsis a distinct input tocheck(): the exact tool-call JSON object presented atauthorization time.
invoked_paramsis the corresponding JSON object observed by the body-owningwrapper immediately before the actual invocation. Raw values of either are hashed and never logged. The
hashes land as
authorized_params_hashon the allow andinvoked_params_hashon the outcome. Twoobservations at two moments; substitution between them is visible only because both exist. A
pre_hook_onlycapture has no second observation and never copies the first.Numeric profile
params_c14n_v1, shared by Python and TypeScript: the JSON values the canonicalizeraccepts, with one rule stated runtime-neutrally: every mathematically integral number outside
±(2^53−1) is rejected, regardless of the host's numeric type. Outside the domain: no hash, and
params_hash_reason: unsupportedon the record whose hash is absent.Commitment: decode
params_salt(32 lowercase hex characters on therootentry) to its 16 raw bytes,then
SHA-256(raw_salt || UTF8(JCS(params))), lowercase hex. The salt prevents linking argumentequality across chains. It does not help inside a bundle, where a low-entropy argument is recoverable by
enumeration, and multiple exports of one chain share it. A deployment that must not disclose argument
equality omits the hashes; the verifier reports
params: not recorded. Cross-runtime vectors arerequired for every accepted boundary and every rejection. Every field named in this document is added to
the evidence exporter's allowlist.
5. What the verifier reports
verify_bundlegainsexecution_binding. Records are schema-validated before classification: fieldtypes, formats, enums and conditional requirements (an unknown
body_state, a missingerror_codeonraised, an illegal conditional field, a negative or non-integer duration, a malformed hash) areinvalid_alloworinvalid_outcome, each naming the record.Per call,
observation: observed (an outcome exists, bound correctly;abandonedanddeferredare observed, reported with their state) · unobserved (
capture: pre_hook_only; no outcome waspromised) · unaccounted (an outcome was promised and is absent). Per node,
lifecycle:finalized (
done) · in_progress (neitherdonenorkill; pending calls reportin_progress, a snapshot, not a verdict) · revoked (killwith nothing pending; never escalatesthe aggregate) · revoked_with_pending (
killwith never-reported calls).Aggregate: clean · incomplete · failed.
cleanasserts one thing: the producer's recordsare internally consistent. It does not assert the lifecycle of every action is known: an
abandonedcall is a precise observation whose action outcome stays unknown, and it leaves the aggregate
cleanwhile its own state says so. Any
unobserved,in_progressorrevoked_with_pendingmakes theaggregate
incomplete, neverclean, even where it is no producer fault. Anunaccountedcall in afinalized node makes it
failed.Binding coverage is its own axis, reported beside the aggregate, never folded into it:
params_coverage: complete | partial | none, from how many calls carry both hashes. A bundle whosedeployment omitted the hashes can be
cleanwithparams_coverage: none, and the report says both.Binding failures, each a reason code:
outcome_without_allow(an outcome whosecall_idhas no allowin this chain and node; a record copied in from anywhere else lands here),
duplicate_outcome,duplicate_call_id,outcome_before_allow(outcomeseqnot after its allow's),cross_ref(amatching
call_idon a different node within the same chain),params_mismatch(both hashes present,different). An entry whose
chain_iddiffers from the bundle's is alreadychain_id_mismatch, socross-chain splicing fails before binding is examined.
Verification never consults current authority state: the committed
allowis the historical authorityfact for this layer, and a revocation that happens later does not retroactively turn the chain into a
contradiction. The verifier accepts an independently retained expected anchor or head as input; verifying against only
the bundle's enclosed anchor detects tampering since that anchor, nothing earlier, and the report says
which it had.
What passing proves. The retained records are internally consistent: every authorization has at
most one bound observation record, none exists without its authorization, the bindings hold, and
nothing was altered since the anchor the verifier held. Parameter equality is established only for
calls where both hashes are present; elsewhere only identity and order binding was checked. What it
does not prove: who wrote the records. The library trusts its own process, and in-process code can
append chain-valid entries; whether the effects happened in the world; or that every call that ran was
recorded — a bypassed interception point leaves nothing, and the bundle verifies. These are the
producer's books, checkable for consistency; they are not a witness.
6. Crosswalk with crewAIInc/crewAI#6030
The PR is open and contract-only; this maps to its text as fetched, and the adapter's alias layer, not
the schema, tracks it.
decision_idcall_iddecision_idwith this valueparams_hashauthorized_params_hashinvoked_params_hashhas no #6030 equivalentoutcome∈ executed/blocked/error/timeoutbody_state∈ returned/raised/abandoned/deferredreturned→executedandraised→error. It may emitblockedonly from its own denial path, when it prevented entry into the body, andtimeoutonly when it enforced and observed its own deadline. An external cancellation maps to neither; it isabandonedhere and nothing thereidempotency_key, duplicate-terminal denialintent_ref,intent_digest,normalized_scope,targetboundary_id,running_countchain_id+nodeand ledgerseqprovide correlation and ordering; they do not implement #6030's record-count contracttool_output_hashpolicy_*,credential_*, expiry, supersession, extensions)7. Receipts
receiptis unverified carriage and labelled so:{type, ref, digest}: the format and version, thatformat's own correlation id, and SHA-256 over the envelope bytes as received. The library verifies
nothing about it. A verifier that wants it checked does so in the receipt format's own terms, and can
join the artifacts only where that format carries our
call_idin a field it provides.8. Deferred, deliberately
Runtime duplicate prevention (
idempotency_key, reservation states, cross-process coordination):specified with the implementation that provides its guarantees, as its own reviewed change. Until then
this layer records keys when adapters supply them and claims nothing about duplicates.
Verifier capture policy (
required_captureprofiles, trust ranking of capture labels): its owndesign; capture labels here only route calls into observed/unobserved reporting and are never trusted
as claims of quality.
Observer attestations: the envelope a neutral matching service signs is being designed against a
concrete implementer of such a service and will be reviewed with them. Until then outside observations
travel as receipts, unverified.
9. Versions
One schema version per chain, stated on the
rootentry. Supported-version and bundle↔anchor equalitychecks are on
mainand ship in the next release; root↔bundle equality and rejection of mixed entryversions are part of this change. The verifier dispatches on version: v1 bundles get today's checks and
execution_binding: not applicable; unknown versions areunsupported_version. Chains are created atone version and never mix; upgrading a deployment means new chains, not rewritten ones.
10. Vectors
One per reason code in section 5, and: concurrent duplicate-outcome append; allow-versus-deny pending
behaviour; duplicate ids across allow and deny; pre-commit failure and each post-commit persistence
failure; the restart rule; the
CompletionResultshape on complete-with-pending; everybody_stateconditional field and each invalid state; raw-salt versus hex-salt hashing; safe-integer boundaries,
integral floats, negative zero, non-finite numbers, lone surrogates and unsupported objects; each hash
independently absent or unsupported; hashes deliberately omitted by a deployment; root, bundle and
anchor version mismatches and mixed entry versions;
pre_hook_onlywithout an outcome; every aggregatestate including each way "not clean" arises; a malformed receipt digest; a killed node with a late true
record and with a never-reporting call; a retry after
CommittedAuditError(two calls, no shared-operationstatement); an in-progress snapshot; an
abandonedcall inside acleanaggregate; Python and TypeScript byte-for-byteparity throughout.
11. The ask
Section 3's
body_statevocabulary, read against what your wrapper can actually see. Section 6'smapping rules for your own vocabulary, if you carry one. And whether the narrowed scope is worth having:
an observation record and its consistency checks, with prevention and observation-by-others deferred.
Ships as 0.9.0, Python and TypeScript, if the vectors hold.
All reactions