Skip to content

Add Adversarial Execution Evidence predicate (v0.7) - #570

Open
astrogilda wants to merge 41 commits into
in-toto:mainfrom
astrogilda:predicate/adversarial-execution-evidence
Open

Add Adversarial Execution Evidence predicate (v0.7)#570
astrogilda wants to merge 41 commits into
in-toto:mainfrom
astrogilda:predicate/adversarial-execution-evidence

Conversation

@astrogilda

@astrogilda astrogilda commented Jul 15, 2026

Copy link
Copy Markdown

Adversarial Execution Evidence predicate

This adds a predicate for the evidence produced by deliberately executing an untrusted artifact against a known adversarial corpus inside a containment substrate: agent tools, MCP servers, plugins, build steps. The producer runs the artifact, injects the corpus, and signs what the substrate intercepted.

The predicate has been through fifteen rounds of review here, and @Rul1an has written a checker from the text alone and runs its conformance corpus, so the field shape is settled enough to read as written. They are the same reader in every reference below. I still want review of the fields before the vetting meeting. To answer the new-predicate guideline questions up front:

The use case is an admission controller gating a third-party MCP or agent image on evidence it was detonated against a named corpus under an enforcing catch policy, and an auditor re-verifying a specific interception offline without trusting the producer's infra. Existing predicates do not cover it: runtime-trace carries raw monitor activity with no corpus binding, no coverage denominator, and no per-event signature; SCAI carries attribute assertions, not an adversarial corpus with recomputable outcomes; VSA and SVR carry policy verdicts computed downstream of evidence like this. This is the evidence layer those consume, so verdict semantics stay out of scope here. The policy question it answers is which attacks this exact image faced, what the substrate did about each, and under what network posture. A consumer recomputes all of that from the attestation plus the producer's published taxonomy, with no call back to the producer.

The shape carries an explicit doesNotAssert negative scope, and five further design properties motivate it. I'm happy to defend or change any of them in review:

  • A recomputable result: a deterministic function of the carried evidence, which a consumer re-derives for itself instead of taking the producer's word for it.
  • An attack-granular coverage binding, where the coverage denominator is a digest-committed manifest mapping each class to its attack ids and carried in the attestation, so an edit to the assessed set changes corpus.digest.
  • Independently signed intercept records (DSSE-shaped, verify-then-read) under a single predicate-level batchRoot.
  • An I-JSON safe-integer profile on every rail, so canonicalization is byte-identical across languages.
  • A bounded producer extension surface. The aee member prefix is reserved for future versions of this predicate and everything else in an observation payload stays producer territory, but no member of that territory is read by a conforming verifier: a producer-defined member MUST NOT affect structural validity, MUST NOT affect result, and MUST NOT affect the evidence tier, whether or not its values can be ordered. A producer-defined ordered axis additionally MUST NOT be ranked by a verifier and MUST NOT be composed by weakest input across records or rows, because the two axes this predicate does order, basis and method, are ordered only because a normative reader consumes them, and a producer axis acquires no such reader by sharing the envelope. That constraint sits in the paragraph granting the territory, rather than beside each future member, so an implementer meets it before the member exists and not after the first one has shipped. The first version of this rule reached only ordered members. They applied it to their own tooling the day it published and reported two gaps: it missed their unordered producer members, and one of their checkers was gating validity on a producer member's value, which their own design document forbade. Both halves of the wording above come from that report.

One thing I'd specifically like the maintainers' read on: whether evidence that carries no verdict belongs as its own predicate or should be framed relative to SCAI.

We build monitors that would emit this downstream of runtime traces. For folks in #557 and #568 working on eBPF and CI monitors (@rung, @stupendoussuperpowers): this is designed to wrap and bound the outputs of a trace, and I'd value knowing whether your tools' trace-policy and scope outputs map cleanly onto observationEnvironment and the coverage sets.

Vetting status (v0.7, suiteRevision 25)

This pull request is at predicate version v0.7, and its head carries it. The conformance corpus is at suiteRevision 25, which is 250 vectors: 55 accept, 193 reject and 2 indeterminate. The indeterminate class is a third disposition rather than a rounding of the other two, and a statement lands there when the corpus declares that more than one conformant reading is available. aeeChainScope is an array a machine can compare, canonically sorted and free of duplicates, of registered dimension tokens with a two-sided equality gate that closes both scope-narrowing and coarse-side pooling. The whole statement is parsed as strict I-JSON; every string literal must be a well-formed sequence of Unicode scalar values checked on the raw bytes, which now excludes the Unicode noncharacters RFC 7493 §2.1 forbids; and JSON nesting is bounded normatively at 128 with its counting rule stated (the number of open containers, the outermost brace being depth 1). An arming payload may carry a read-first aeeBindingVersion; armedAt requires a zero UTC offset; an out-of-range observationRefs index is a fault on any row; duplicate attackId rows are malformed; the single-subject requirement holds on a statement of any basis; and the three coverage sets are a disjoint partition. Every interpretation decision is locked by a forcing vector under a CI-checked registry, and the spec says plainly that a reading no vector exercises is untested, not confirmed. Working in a different language from the published text, they have run this suite at six of its revisions, and not at the rest: there is no posted run for suiteRevisions 4, 7 through 21, or 23 through 25, so this pull request publishes no independent score at any of those. What they have posted is 125/125 blind at revision 1, 138/138 spec-diff-led at revision 2, 140/140 at revision 3 as a first run by an unchanged build whose reason-map rule predated the two new vectors, 149/149 at revision 5, 153/153 at suiteRevision 6 (2026-07-28, aee-checker#4), and at v0.7 and suiteRevision 22 a blind first run of 179/232 followed by a directed run of 232/232. The blind number is the informative one and it is not flattering. They partition the 53 first-run mismatches by the message their checker emitted: 42 on one run-binding derivation, 7 returning valid with no reason, and 4 answering pass_indirect where the corpus expects pass. That partition is by message and not by cause, and they state in their own report that attributing a recovery to a particular fix would need a bisection against the blind build, which is unrecoverable, so no causal residual follows from it. They also record that the blind build was never committed on its own, so the 179 is not independently reproducible and their index carries an explicit null digest saying so. Their run records and report for this revision are pinned at reports/v0.7-RUN.md, and our own implementation notes are in docs/IMPLEMENTATION-REPORT.md.

A protobuf definition ships with this revision, transport/codegen-only: its JSON output is never re-canonicalized for signing (proto3 ProtoJSON is not RFC 8785), since DSSE signs the body bytes verbatim.

Edited 2026-08-20 to correct the corpus counts: suiteRevision 25 is 250 vectors, 55 accept and 193 reject, and this description carried suiteRevision 24's 248, 54 and 192 against revision 25's name.

Evidence produced by executing an untrusted artifact against a
digest-committed adversarial corpus inside a containment substrate:
recomputable outcome, attack-granular coverage binding, independently
signed intercept records under a predicate-level batch root, and explicit
negative scope. Verdict semantics deliberately excluded; they belong in a
downstream summary predicate such as VSA.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@Rul1an

Rul1an commented Jul 19, 2026

Copy link
Copy Markdown

Taking you up on the early-draft invitation. Mostly field-level, plus one gap I think this predicate is unusually exposed to.

observationEnvironment pins what the substrate was configured to do and coverage pins what was assessed. Neither pins where an observation came from. interceptRecords are signed by the substrate, but the signature binds emission rather than vantage: the record's content is producer-defined, so an egress_captured the substrate intercepted at the network boundary and one derived from the executed artifact's own stderr both verify identically.

That interacts badly with the property I like most in the draft. Because result is a deterministic function of the carried evidence, both of those recompute to the same fail, and the recompute is what gives a consumer confidence in the first place. It is also why the record signatures do not close this: interceptRecords is optional and the recompute reads containmentObserved, so a fail is reachable with no signed record at all. A self-reported observation that lands on fail through a correct recompute is worse than one that does not, because everything downstream now reads as checked.

This is not the coverage question from #557. catchPolicy and networkPosture do pin the substrate's configuration, but basis is per-observation rather than per-run: the same enforcing policy is compatible with a row the substrate saw and a row the artifact reported, and the recompute reads the row.

You have already drawn the distinction once. networkPosture is specified as the substrate-authoritative egress posture, which is a claim about vantage rather than about content. Generalizing that to the observations themselves would close the gap: a per-row basis on attackResults with a small closed vocabulary, along the lines of substrate_observed, artifact_reported, inferred, with unknown labels failing closed the way containmentObserved already does.

actualLayer is adjacent but answers a different question, and its clean-run behavior is not stated. It names which enforcement layer acted, so presumably nothing on a pass, which is the case where a consumer most wants to know whether anything was positioned to see. Worth pinning either way.

On your two questions, though you asked the maintainers and I would defer to them on the first. Verdict-free evidence reads as its own predicate rather than SCAI, since the recompute rule is normative here while SCAI leaves attribute and evidence formats to producer and consumer with no re-derivation requirement. On does_not_assert, I would just rename it. Your changelog says 0.1 to 0.2 were internal iterations, so the spec has no deployed readers to keep, and any old-name tolerance is your producer's migration concern rather than something the wire format has to carry. A permanent alias is the one option I would avoid, since two accepted spellings means two canonicalizations for the same content and you hold I-JSON discipline everywhere else.

I can send the three-value vocabulary as a suggested diff if you want it concrete.

@astrogilda

astrogilda commented Jul 19, 2026

Copy link
Copy Markdown
Author

You found the real hole. The recompute is the property I care most about, and you're right that it's exactly what makes the vantage gap dangerous. A fail derived from rows the artifact itself reported verifies and recomputes identically to one the substrate intercepted. And because the recompute is what buys confidence, everything downstream reads it as checked. interceptRecords being optional means the signatures never close it either; a fail is reachable with zero signed records, off containmentObserved alone. Self-reported observation that survives a correct recompute is worse than none, and I'm not going to argue with any part of that.

I've adopted your fix as proposed and pushed the revision, so what is below is the committed text itself. basis is now required on every attackResults row, with the closed three-value vocabulary: substrate_observed (the substrate saw the event at its own vantage, network boundary, syscall supervision, or VM introspection, independent of the artifact's cooperation), artifact_reported (derived from the artifact's own stdout/stderr, exit status, or self-emitted logs), and inferred (indirect derivation, say a post-hoc state diff). A missing basis, or any value outside those three, fails closed through the same mechanism as an out-of-vocabulary containmentObserved label. The row forces result to fail and can support nothing stronger, and the recompute rule now reads it directly. I did not let an unknown basis degrade to artifact_reported, since that would launder unknown vantage into a weaker but accepted claim, which is the same failure in a nicer suit. The spec also says the quiet part now. Consumers MUST be able to gate on basis, a fail supported only by artifact_reported rows SHOULD be treated as weaker than one carrying a substrate_observed row, and a consumer MAY reject it outright. That keeps the gating decision on the consumer side without the predicate ever carrying a policy verdict.

The actualLayer clean-run behavior is pinned too. On a clean row the producer MUST emit the literal none, explicit rather than omitted, so that "nothing needed to act" is distinguishable from an accidental omission. Your positioned-to-see question turned out to be the best argument for basis on clean rows as well: a clean row carrying substrate_observed states the substrate had vantage and observed nothing, which is the claim a pass actually rests on. That keeps actualLayer answering only which layer acted, and keeps vantage from being loaded onto it.

On the rename, agreed and done. Two accepted spellings means two canonicalizations for the same content, so no alias; the old spelling is rejected, and migrating old producer output is our producer's problem rather than the wire format's. The field is doesNotAssert, and since a rejected spelling is a breaking wire change I bumped the predicate to v0.4 (Type URI and Version both), with all three of these changes in the changelog entry.

On SCAI, same read for the same reason: the normative recompute rule is what makes this its own predicate, since SCAI deliberately leaves attribute and evidence formats to producer and consumer with no re-derivation requirement. The call stays with the maintainers, but a second reviewer landing on own-predicate off the recompute rule is the strongest version of the argument I could bring to vetting.

The suggested diff is the smaller half of your offer, and I'd take the stronger version. The vocabulary is committed text now, and what I want from you is the read you gave the recompute, aimed at the observation that fits none of the three cleanly. The seam I'd probe is between substrate_observed and inferred: a substrate-adjacent monitor with real vantage that isn't the enforcing substrate itself. If that deserves a fourth value, now is the time, while there are still no deployed readers to keep.

Edited 2026-08-31: prose only, for readability. No field name, vocabulary value, version or claim changed.

@Rul1an

Rul1an commented Jul 20, 2026

Copy link
Copy Markdown

Read it the way you asked, against the recompute rather than the prose, and the three values hold up better than the seam you flagged. Let me take that seam first, then hand you a sharper one.

The substrate-adjacent monitor does not want a fourth value, because what separates it from the enforcing substrate is enforcement role, not basis. A passive tap that saw an egress at the network boundary and the inline gate that intercepted the same egress make the identical claim on that row: each observed the event at its own vantage, independent of whether the artifact cooperated. What differs is that one was positioned to act and the other only to watch, which is an enforcement-role fact rather than a basis one, and whether anything acted is the separate question actualLayer answers. This works only if substrate_observed names a class of vantage, cooperation-independence, rather than the identity of the enforcing substrate. Under the identity reading your seam is real and wider than one monitor: the adversarial corpus endpoint that logs the inbound connection it received is neither the enforcing substrate nor the artifact, yet plainly not artifact_reported. But the fix for that is to sharpen substrate_observed to name the vantage class, since the corpus endpoint and the enforcing substrate sit at the same basis and differ only in who, if anyone, could act. Either way, no fourth value: adjacency is a fact about enforcement role, which basis does not and should not measure, not about the cooperation-independence that separates substrate_observed from artifact_reported.

The seam I would actually probe runs inside a single value rather than between two, and it passes through one of your own substrate_observed examples. Take VM introspection. A trap on a write catches the event as it happens, but a snapshot-to-snapshot memory diff has the same independent vantage, the guest cannot forge the hypervisor's read, and yet it is a reconstruction across the semantic gap rather than a caught event, so it can miss a transient raised and undone between the two snapshots. VM introspection therefore splits by directness while staying substrate_observed, and the same physical evidence can land elsewhere too: a state diff parsed from the artifact's own logs is inferred and not independent at all. inferred therefore says nothing about vantage, which is the one thing basis most needs to preserve.

That matters for the recompute for the same reason unknown basis did. You refused to let an unknown vantage launder down into artifact_reported, since it would buy a weaker but accepted claim. The symmetric leak is upward: unless the independence of an inference's inputs is itself stated, a reconstruction built on the artifact's own testimony reads as if it carried substrate vantage. The cheap version, while there are no deployed readers to keep, is one line: a fail supported only by inferred rows reads no stronger than one supported only by artifact_reported, and if you want a reconstruction from independent inputs to count for more, the independence of those inputs has to travel the way you made basis itself travel.

Everything else in the revision reads clean. basis required with the closed vocabulary and fail-closed on unknown, the gating language moved onto the consumer without the predicate holding a verdict, actualLayer pinned to literal none on clean rows, and basis kept on clean rows so a pass states the vantage it rests on, all land where I hoped. Rejecting the old spelling rather than aliasing it is the right I-JSON call, and the v0.4 bump is the right breaking-change signal for it. Glad to be second reviewer at vetting time, holding an author's own predicate to the recompute rather than the prose, if that helps carry it.

@astrogilda

astrogilda commented Jul 21, 2026

Copy link
Copy Markdown
Author

Both halves taken. Your vantage class reading is now the definition itself: in the new text substrate names cooperation independence, and your corpus endpoint sits next to the tap and the inline gate as the worked instance of same basis, different enforcement role. The seam inside substrate_observed was worse than you stated it, too, because my own examples already instantiated it. VM introspection sat under substrate_observed. Post-hoc state diff sat under inferred. A hypervisor snapshot diff is both at once, so two values claimed the same evidence, and the closing question from my last reply got the answer it deserved: the hunt for a value that fits none of the three fails because the three values were two axes fused into one vocabulary, vantage and how the observation was made.

I did not take your cheap fix, for a structural reason rather than taste. A flat "inferred reads no stronger than artifact_reported" binds only the producer who volunteered the weaker label, and with my examples ambiguous the same snapshot diff pipeline could defensibly label substrate_observed, so the line would have penalized the honest producer without constraining anyone else. Your strong version is the one that holds: the independence of an inference's inputs has to travel. The revision at def32f0 does that literally. basis collapses to two values, substrate and artifact, defined as the vantage of the claim's weakest input, with a stated criterion for what counts as artifact-sourced: a channel the artifact can populate arbitrarily without performing the claimed event. Testimony about an event, versus the event itself. That is why an egress capture does not inherit artifact even though the artifact authored the packet bytes, and why its stdout always does. inferred is deleted with no successor, and your two state diffs land at (substrate, reconstructed) and (artifact, reconstructed). The weakest-input rule is your half of this design, written down.

The directness axis is my extension. It has to live on the wire rather than in producer vocabulary for the same reason basis did: the recompute and the documented gating on the pass side read it, and transient tolerance cannot be gated fail-closed across producers on open per-producer labels. method is intercepted or reconstructed, required, closed, fail-closed, and it composes the same way basis does. A claim inherits reconstructed from any input derived from state after the fact, while decoding a channel that was armed before the event does not demote a row. Attribution strength stays in the producer's containmentObserved vocabulary on purpose, since nothing normative reads it; that boundary is unchanged from 0.4.

Your transient example also bites hardest where you did not press: on pass. The ordering is two-sided now. While basis bounds a fail over a defined supporting set, method bounds a pass, with (substrate, intercepted) clean rows as the strongest absence claim the predicate can carry and any reconstructed clean row read as tolerating transients between the observed states. Rows fail-closed on either field sit at the bottom of both orderings, which is where a row with unknown vantage belongs.

One more change completes your own no-fourth-value argument. You delegated enforcement role to actualLayer, but 0.4 gave your passive tap nowhere to land, since a caught row where nothing acted was undefined. actualLayer is now required on every row, and none is valid on a caught row, where it states the event was observed and no layer acted, which is the enforcement role fact your argument needed a home for.

Mechanics: the three 0.4 spellings are rejected with no alias, same protocol as the does_not_assert rename, licensed by zero deployed readers. I resisted a third axis (confidence, attribution) as territory for producer vocabulary. Naming is open. If the maintainers prefer observationMethod or directness over method, that is a cheap rename before vetting and their call. The proto lands once the field shape settles, as the PR body says.

The main thing I want your read on is whether the weakest-input rule and the artifact-sourcing criterion close the upward leak you named. Beyond that, check me on where the method/attribution line sits, and on whether a caught-row none covers your passive tap the way you meant it. Yes to second reviewer at vetting.

Edited 2026-08-31: formatting only. A few repeated field names lost their backticks after their first mention; no name, value, commit or claim changed.

@astrogilda
astrogilda force-pushed the predicate/adversarial-execution-evidence branch from 92424ac to 4133b14 Compare July 21, 2026 05:36
@Rul1an

Rul1an commented Jul 21, 2026

Copy link
Copy Markdown

That is the right cut, and the reason the hunt failed is as you put it: three values were carrying two questions, so no single value could fit an observation that varied on both. Weakest-input over vantage and directness is the composition that makes the two axes independent again, and the artifact-sourcing criterion, a channel the artifact can populate without performing the claimed event, is the sharp version of what I was reaching for with testimony. Egress-capture-is-substrate-because-the-packet-had-to-be-sent is the case that proves the criterion carries its weight.

On your main question, the weakest-input rule closes the leak I named, but only for a producer telling the truth about its inputs, and that deserves being exact about because it is the same shape as the gap I opened first. basis and method are still labels the producer writes and the recompute reads, not facts the recompute derives from carried evidence. A pipeline reporting everything off the artifact's own testimony can still stamp substrate on every fail row and mint the strongest fail you can carry, with no substrate anywhere in the loop. Weakest-input disciplines the honest producer, whose weakest input drags the row down. It does not bind the one who simply labels the row substrate, and the strongest claim staying self-certifiable is exactly the hole from my first comment: interceptRecords optional, the recompute reading a producer field, a fail reachable with zero signed records.

The close is to make the strong label cost the one thing a self-reporter does not have. A row may carry basis substrate only if a substrate-signed interceptRecord covers that observation; absent it the row cannot claim substrate and fails closed toward artifact. interceptRecords stays optional in general, artifact rows never needed a signature, but substrate becomes the value you cannot assert without the substrate's own signature over the row. That makes basis substrate a claim the substrate signed over the row rather than one the producer asserts, so asserting it now requires the substrate's own key, which a pure self-reporter does not hold. It relocates the trust root from the producer to the substrate rather than dissolving it, which is where substrate vantage should cost, and it composes with weakest-input unchanged: an input lacking substrate backing is just another weakest input that caps the row. If the signed record also states how it observed, intercepted or reconstructed, then method for substrate rows rides that signature too, so the strongest value on each axis rests on the substrate rather than the producer, substrate on the fail side and intercepted on the pass side. method on artifact rows can stay a declared label, since artifact has already capped what that row supports.

On the passive tap, yes, and making actualLayer required on every row is what closes it. A tap that saw an egress it could not block lands at substrate, intercepted, actualLayer none, contained false, and that reads correctly as a strong fail: an independent vantage watched the thing happen and nothing stopped it. none on a caught row was the missing home for the enforcement-role fact I split off, so requiring it everywhere rather than leaving it undefined is the right completion of the argument against adding another value, not a new question.

On where the method and attribution line sits, I think you have it right: method is a fact about the observation, how directly it was made, while attribution is a fact about the mapping from an observation to a named attack class, and those vary independently. The one thing I would check is that containmentObserved is not now carrying both. It holds the containment bit the recompute reads, which is normative, and, as I read the revision, attribution strength now also lives in its vocabulary as non-normative nuance. A single field with a gated meaning and an ungated one beside it is the overload we just spent two rounds pulling out of basis and actualLayer, so it is worth confirming the recompute reads only the containment bit and nothing downstream leans on the attribution nuance to move a result.

Yes to second reviewer at vetting. On naming, method or observationMethod or directness all read fine and it is the maintainers' call; the shape underneath, two required closed axes with weakest-input composition and a two-sided ordering, is the thing I would freeze.

…nd pin actualLayer clean-run behavior

Add a required basis field to every attackResults row with a closed
three-value vocabulary (substrate_observed, artifact_reported, inferred).
The recompute reads the row, so the row must carry its own vantage:
without it a substrate-intercepted observation and one transcribed from
the artifact's own output recompute identically. Missing or unknown
basis values fail closed exactly like out-of-vocabulary
containmentObserved labels, and consumers can gate on basis; a fail
supported only by artifact_reported rows is a weaker claim a consumer
may reject.

Pin actualLayer on clean rows: producers must emit the literal none
rather than omitting the field, so an unacted row is distinguishable
from an accidental omission; vantage on clean rows is carried by basis.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
…NotAssert and bump to 0.4

Rename the negative-scope field to the lowerCamelCase convention with a
single canonical spelling and no alias: two accepted spellings would
mean two canonicalizations for the same content, against the I-JSON
discipline the spec holds everywhere else. Versions 0.1 to 0.2 were
internal, so there are no deployed readers to keep; migrating old
producer output is a producer concern.

Bump the predicate to 0.4 and record the review-driven changes (basis
field, actualLayer clean-run pin, the rename) in the changelog.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
…o vocabulary labels

The actualLayer clean-run rule defined a clean row as any row whose
containmentObserved label is not in the caught set. An out-of-vocabulary
label is also not in the caught set, and such a row is fail-closed, not
clean, so the definition contradicted the fail-closed rule. Restrict the
clean-row definition to labels from the published vocabulary.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
…ing to the changelog

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
…irectness axes and bump to 0.5

Review of 0.4 showed the three-value basis vocabulary conflated two
orthogonal properties: whether the artifact could forge the observation
(vantage) and whether the event was captured as it occurred or derived
from state after the fact (directness). The spec's own examples
instantiated the collision: a hypervisor snapshot diff fit both
substrate_observed (VM introspection) and inferred (post-hoc state diff).

Replace basis with two required, closed, fail-closed row fields: basis
(substrate | artifact), the vantage of the claim's weakest input with a
stated artifact-sourcing criterion, and method (intercepted |
reconstructed), the claim's directness with the same weakest-input
composition rule. Delete inferred with no successor; reject the 0.4
spellings with no alias, keeping a single canonicalization per content.

Make actualLayer required on every row and extend the literal none to
caught rows, where it states observed-but-not-enforced, so enforcement
role travels there and never leaks into basis. Add two-sided consumer
strength orderings (basis bounds a fail, method bounds a pass), a
coherence check of row claims against the pinned observation
environment, a row-internal check that intercepted caught rows reference
verifiable intercept records, and state the row-travel design invariant
under Parsing Rules.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@astrogilda
astrogilda force-pushed the predicate/adversarial-execution-evidence branch from 4133b14 to def32f0 Compare July 21, 2026 13:09
… coverage records and bump to 0.6

Review of 0.5 showed a substrate basis was a bare producer claim: a row
could declare the strongest vantage while referencing nothing, so a
self-certified strongest pass or fail was indistinguishable from an
earned one. Back every substrate row with substrate-signed coverage at
two separate gates.

The first gate is validity, and it is a pure function of carried bytes.
References must be non-empty, resolve in range, and class-match; every
covering payload must be canonical +json carrying the reserved members,
with a run binding equal to the one derived from the statement; a row's
method may be no stronger than the weakest method its covering records
signed; and batchRoot must recompute. Failing any of these makes the
attestation invalid for every consumer, and evaluating them is a
normative consumption precondition rather than an optional lint, so a
consumer that reads result or credits a row must run them first.

The second gate is a derived per-row evidence tier holding the one
genuinely trust-relative question: whether the covering signatures
verify against a key the consumer's policy names as a substrate
observation key. Rows resolve to attested, unattested, or declared, a
consumer with no pinned substrate root treats every substrate row as
unattested, and an unattested substrate row ranks where an artifact row
ranks. The tier never alters result, and the may-reject-never-downgrade
rule is retained.

Solve the clean-row case, where an armed vantage that captured nothing
produces no per-event record to sign, with a run-level instrument pair:
an arming record stating a live capture vantage was armed before corpus
injection, and a sealed record stating the vantage stayed armed to
run-end with a zero or self-bounded run-wide drop count and an unchanged
posture digest. A clean intercepted row is valid only when both cover
it, which closes arm-then-drop, drop-count overflow, and mid-run posture
flips in byte-checkable text.

Carry the observation vocabulary in the attestation, as labels, a caught
subset, and a canonical digest, so the recompute and the validity gate
no longer depend on a document that does not travel with the statement
and archived attestations stay verifiable. Rename the run-start digest
to runEntropy, state its pre-image, and fold it into a versioned run
binding so identical-configuration re-runs derive distinct bindings; the
binding is anti-splice, not a freshness challenge.

Rename interceptRecords to observationRecords and interceptRefs to
observationRefs, pin batchRoot to RFC 6962 with domain separation and
duplicate rejection, replace the trust-boundary paragraph with a field
partition and an honest single-root key model, and state the
composition and run-population non-claims. Breaking, with no aliases for
the old spellings, under the same single-canonicalization rule as
earlier renames.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
…row ordering redundancy

The observation-environment list carried two conjunctions, and the
clean-row ordering stated self-reported absence twice. Fold the
unattested substrate clean row into the single weakest-case sentence
alongside artifact clean rows. No normative change.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@astrogilda

astrogilda commented Jul 21, 2026

Copy link
Copy Markdown
Author

Both halves of the gate taken. I want to lead with the two places my first pass would have overclaimed, because you would have caught them on the first read and I would rather retire them myself. The revision at b5acaa5 is v0.6; as before, this reply lands after the push so you are reading committed text.

The first place is the one you care most about, the recompute. My instinct was to add the coverage gate as a second object every consumer derives beside result, and leave that member itself reading basis as a raw label. That is your round-three hole wearing a MUST-derive obligation. A consumer that reads result and skips the object is exactly as deceived as under v0.5. So I split the gate by what it actually depends on. Four of its steps are pure functions of the carried bytes, with no key and no policy in them: the references resolve and are in range and class-match the row's method; every covering payload is canonical and carries the reserved members with a run binding equal to the one the verifier derives; the row's method does not exceed the weakest signed method across its covering records; and the batchRoot recomputes. Those four are now VALIDITY requirements, at the same altitude as a missing actualLayer. I made them consumption preconditions in so many words: a consumer that consumes result or credits any row must evaluate them first, and on failure the attestation is invalid and the result must not be consumed. I am being pedantic about that sentence because a gate nobody is required to run is not a gate, and the result-only consumer you constructed is bound only if reaching it is defined to go through the gate. Only the fifth step is genuinely trust relative, whether the covering signatures verify against a key you name as a substrate key. That one stays a per-row derived tier, because a validity rule that varied with your trust anchors would break the recompute we have argued everything against. So the recompute is still pure, but a valid attestation can no longer carry a strong substrate row whose coverage is dangling, class-mismatched, cross-run spliced, or unrooted. While making that claim honest I had to fix one more purity leak you had not called yet. Caught versus clean used to be decided by my published vocabulary, a document that does not travel, which would have made validity a function of something I could edit after signing. The vocabulary and its caught subset now ride in the attestation, digest-pinned the same way the corpus manifest already was, so the classification is carried bytes too and an archived attestation stays verifiable if my documentation moves or I disappear.

The second place is the single-key topology, and here I have to concede the frame before I describe the mechanism, because your read falsifies the confident version in two lines. In the deployment we actually ship, the substrate signer and the assembly signer are the same key held by the same operator. Under that key the tier does not defeat a pipeline with no substrate in the loop. An operator holding the key can hand-author an arming record with the right derived run binding and a valid signature without any substrate ever running, and every clean row it points at derives attested. A signature proves key possession, and nothing about it proves a substrate executed. So the named attacker, the self-certified strongest pass and strongest fail, is closed only where the substrate observation key is held apart from the assembly plane, which is a SHOULD we do not yet ship. What the tier buys under one key is smaller and real. Against a party that does not hold a substrate key, a downstream tamperer, it still binds every record to this run so a foreign-run record cannot be spliced in, still commits the whole record set under batchRoot so nothing can be dropped, and still pins method to what the substrate signed. I have graded the threat-model list accordingly, and the trust-boundary section now reads as a field partition with a key floor, where it used to read as a triumphant walkthrough. Walk your artifact-testimony pipeline through the gate and the defeat holds only where the pipeline does not hold a key you name as a substrate key, a precondition that now lives in the sentence itself and no longer 20 lines below it.

Now the mechanism, with those two retractions in view. Your rule keys on a substrate-signed record covering the observation, and your pass-side sentence wants intercepted on the pass side resting on the substrate too. Each collides with a line I had already committed, that a clean row's interception has nothing to sign. So the pass side needed the instrument your sentence presupposes without naming. It also needed a second record, because a single arming record proves the vantage was armed at t0 and nothing more, and a vantage dropped one tick after arming would satisfy the strongest pass claim. That is your own "a missing signal is not a clean one" reintroduced on the pass side. So in v0.6 interceptRecords becomes observationRecords and admits, beside per-event interception records, a run-level arming record and a run-level sealed record. The arming record says a live vantage was armed before injection and pins the posture digest it was armed under. The sealed record says the vantage stayed armed to run-end, and its signed payload carries a still-armed flag, a run-wide count of dropped observations, and the posture digest in effect at run-end. The covering rule is normative and byte-checkable: a sealed record covers no clean row unless the flag is true, the drop count is zero or within a bound the same signed payload declares, and the posture digest equals the arming record's and the pinned one. A clean intercepted row is valid only with the pair. That closes arm-then-drop, silent buffer overflow, and a mid-run posture flip in one instrument. I kept the members semantic on purpose, armed, stayed armed, nothing dropped, posture unchanged, so how a substrate establishes them, a checkpoint chain, a sequence counter, a watchdog, stays producer territory and a substrate with a different mechanism can still comply. I made the drop accounting run-wide, because a per-class rule would need a verifiable mapping from a row to a class inside the seal, and no such mapping exists in carried bytes. A run-wide zero is checkable; a per-class zero would have been the same unverifiable "covers that observation" you objected to, one level down. I am also being deliberate about what even the pair proves: a vantage was armed and stayed armed and the committed set attributes no interception to this attack, which is not the same as no interception having occurred, and because the records are run-wide it is not a claim that this row's specific channel was armed. One more bound belongs on the table now, before someone discovers it later. The attestation speaks for the run it carries, never for a run population. Nothing in this predicate proves I did not run the corpus five times and publish the run I liked. Closing that gap needs a run ledger or a monotonic counter, which is a consumer or policy concern and sits outside the predicate by design.

Making "covers that observation" verifier-checkable meant closing three gaps I had left open in the first pass. The record content is producer-defined, so I had to say what a verifier is allowed to assume it can parse: any record covering a substrate row MUST be a canonical RFC 8785 plus RFC 7493 object whose media type ends in +json, with the reserved members at top level, else it covers nothing. Without that a producer with duplicate aeeMethod keys could make my parser and the signer's canonicalizer disagree, and method inflation would walk back in inside the signature. The record signature is DSSE PAE over payloadType and payload, stated as such, so that member is signature-bound. Then the run binding. My first pass bound five static config digests, which means two runs of the same image under the same policy derive the SAME binding, so a genuine record replays across identical-config runs. I had listed cross-run replay as closed, which is false. It is closed cross-config, not cross-run. v0.6 folds a run-start value the substrate emits into the binding and into the arming signature, so identical-config re-runs derive distinct bindings. The field is called runEntropy, and the name is deliberate: it is not freshness. The binding carries no verifier nonce, these attestations are published rather than challenged, so it is anti-splice, not anti-forge. Replay of a genuine record into a later identical-config run is bounded by a consumer that rejects reuse of a runEntropy value it has seen, not closed outright, and I say so in the text and in the threat model. I cited the CCA composite-token shape for the binding, and I should have flagged that CCA also carries a nonce for freshness that I do not; the binding axis is the only axis where the precedent holds. I also versioned the binding construction itself, a literal inside the pre-image, so if a later version has to change the inputs or the hash the old records stay distinguishable and are not orphaned, and a verifier rejects a construction it does not implement and never guesses between two. And batchRoot was undefined as a tree but I now require the consumer to recompute it. So I pinned it to RFC 6962 with domain-separated leaves and nodes, leaf order matching the array, byte-identical duplicate records rejected, and the tree built by the RFC's own split rule, never by padding the last node, which is the classic implementation divergence in this construction. The mismatch-invalidates rule moved into the batchRoot definition where it belongs; in the tier list it was a category error.

On the reserved members themselves, I would still call them binding structure rather than observation semantics, and I have kept the substrate's own vocabulary out of the wire. It remains the design's most novel joint and I would rather defend it with you now than at vetting: no vetted predicate reaches into a producer-defined payload with reserved members. The alternative I rejected was a producer-declared pointer map telling the verifier where to look and how to decode, because it hands the join back to the party the gate is aimed at. I also dropped the hard exclusivity rule I floated, that an interception index binds one row, because you would have built the counterexample yourself. One captured TLS flow can genuinely carry two exfil payloads for two attacks, and forcing two records over identical bytes manufactures ambiguity. Instead a shared index is allowed when the committed payload evidences each attack, and a row may carry an optional selector member naming the sub-observation it rests on, parallel to its references, with the token content staying producer vocabulary that nothing normative reads. The anti-double-attribution work is carried by the run binding, the batchRoot, and the row's own label. For future evidence shapes I put one forward rule in now: a record whose kind a consumer does not recognize covers nothing and is otherwise ignored, so a later minor version can add a kind, say a TEE quote over the vantage, without old verifiers either breaking or silently crediting it.

On containmentObserved, you were right that one namespace carried a gated meaning and an ungated one. The recompute reads exactly one property, membership in the carried caught set, with out-of-vocabulary fail-closed, and nothing else, not either ordering and not the new gate. I did not split the label set or add an attribution member, and I owe you the reason it is prose discipline here when it was a structural split for basis. The criterion is that an axis earns its own required member exactly when a normative reader consumes it. The recompute reads basis, so basis had to become a member. Nothing normative reads attribution strength or the clean-row window-bleed tolerance, so they stay producer vocabulary under a one-read rule stated at the label's own definition. If you can name any consumer rule, ordering, or gating step that reads the attribution nuance, that criterion says we split it, and I want the example.

As an I-JSON reviewer you would have flagged two smaller completeness gaps, and both are fixed. The run binding pins subject[0].digest.sha256, which is undefined for a multi-subject statement or a subject without a sha256 digest. So v0.6 requires exactly one subject and a lowercase 64-hex sha256 on all six inputs, malformed otherwise, taken verbatim with no case-folding, and the requirement is scoped to statements that carry a substrate row, so a purely artifact-basis attestation does not have to invent an entropy value it has no substrate to emit. And a caught intercepted row with empty references was my only hard validity gate, which a producer dodged by writing any non-empty index. The validity requirement is now that the references resolve and are in range, not merely that the array is non-empty.

The passive-tap reading you traced is untouched, and the frozen shape is untouched: two required closed axes, weakest-input on each, two-sided ordering. The tier is a derived object like result, and the validity half is a parse rule like actualLayer, so neither is a third axis.

Mechanics: version and Type URI move to v0.6 under the meaning-change rule, which the framework's 0.x versioning licenses; the renames are rejections, not aliases. Proto still waits for the shape to settle. observationRecords against evidenceRecords, the reserved-member prefix, and for that matter the tier names themselves, attested in a framework where everything is an attestation invites sentences like "this attestation's rows are unattested", are the maintainers' bikeshed, and I will take their spelling.

Before the freeze I would point you at three seams. The reserved members, per the flag above, are the load-bearing joint; an adversarial producer laundering through them, or a vetted-precedent objection I have underweighted, is where I most expect to be wrong. The runEntropy bound is the second, and I have deliberately left identical-config replay as a stateful-consumer problem rather than a closed one; if a published-attestation predicate can do better without a challenge protocol, tell me how. Third is the run-population bound, where the predicate now says plainly that it attests the run it carries and not the absence of other runs. I would genuinely like to be wrong that no single-attestation format can bind a run population. The second-reviewer offer stands taken on my side.

Edited 2026-08-31: prose only, for readability. No claim, figure, field name or version changed; a few repeat field mentions lost their backticks and nothing else.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
astrogilda added a commit to astrogilda/aee-conformance that referenced this pull request Jul 22, 2026
The source cites spec:NNN line numbers against a specification file that was not
present in the repo (only the JSON schema shipped), so a public reader following a
reference hit a dead path. Vendor a byte-verbatim copy of the v0.6 predicate spec
(tracking in-toto/attestation#570 at b5acaa5) so the repo is self-contained and the
line references resolve. A spec/README records the pin and that the in-toto catalog
namespace, not this repo, is the canonical authority.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@Rul1an

Rul1an commented Jul 22, 2026

Copy link
Copy Markdown

I have been through v0.6 in full at 484bbe0. The two retractions are the right calls and I will not relitigate them; the four byte-pure validity steps as consumption preconditions, with signature verification as the one trust-relative tier, is the correct partition and it closes the result-only consumer cleanly. Taking your three seams in order, leading with the one where you most expect to be wrong, because I think you are half right about it.

Reserved members. The vetting objection has a better answer than the text currently gives itself, and the joint has one real crack.

The precedent first. "No vetted predicate reaches into a producer-defined payload with reserved members" undersells your own lineage. RFC 7519 does exactly this: a JWT claims set is producer-defined, yet verifiers read registered claim names (exp, aud, iss) from inside it, with collision resistance by registration and prefixing. RFC 6839's +json suffix is the vetted license to parse a media type you do not otherwise know. OCI annotations reserve the org.opencontainers.* prefix inside an otherwise free-form map, and the in-toto Statement itself reserves _type inside a JSON object. Your aee prefix plus +json requirement is the JWT registered-claims pattern wearing DSSE, and I would defend it at vetting in those words.

The crack is in the canonicality gate, and it is an adversarial-producer path. A covering payload MUST be canonical per RFC 8785, and a verifier checks that by re-serializing the parsed object and comparing bytes, or by an equivalent sortedness walk. JCS sorts members by UTF-16 code units; most naive re-serializers sort by code points. Those orders diverge when a member name outside the BMP meets one in U+E000 through U+FFFF: a surrogate-led name sorts first under JCS and last under code points. A producer can mint a payload whose bytes are canonical under one reading and not the other, and the two verifiers then split on "covers" versus "covers nothing", which under your validity gate is attestation-valid versus attestation-invalid on identical bytes. You already close the number half of this with the RFC 7493 safe-integer profile, there so that every rail derives identical bytes; the same clause wants a string half. Cheapest fix: require member names in covering payloads to be BMP-only (ASCII would also do, and your reserved members already are), at which point UTF-16 and code-point order coincide and the divergence is unconstructible. The alternative, mandating a true UTF-16-sorting re-serializer in every verifier, puts the burden on the many rather than the one.

runEntropy. I cannot give you challenge-free replay exclusion without state, and I do not believe anyone can: stateless deduplication against a global set is a global view, which is what logs are for. What I can offer is an upgrade to the bound you already have. Your text says the pre-image is the substrate's run-start checkpoint "or beacon head", and I would promote that aside to a SHOULD: make the pre-image include a publicly datable value, a drand round or an epoch ID in the RFC 9334 section 10.3 sense, with the round reference recoverable by the consumer (carrying it in the arming payload as producer vocabulary suffices, since the digest binds it). That buys two things at no new wire members. The arming record gains a proven floor, since a signature over a beacon value cannot predate the beacon round; issuedAt stays the asserted ceiling, and I am deliberately not calling that pair a two-sided proof, by your own asserted-versus-attested rule. And your consumer-side reuse rule gains comparability: two consumers rejecting runEntropy reuse can order the runs they have each seen against public rounds instead of against the producer's clock. The residual, replay visible only to a consumer who has seen the value before, stays a stateful concern, and the text already says so honestly.

Run population. You are right, and there is a strengthening that does not pretend otherwise. No self-contained attestation can prove the absence of sibling runs; that is the split-view problem, and fork consistency is the known ceiling for it. But your own design already holds the answer one level down: the sealed record's run-wide drop count and the checkpoint chain's each-interception-carries-a-higher-sequence rule make observation loss gap-evident inside a run, and the identical construction lifts to runs. One reserved pair inside the arming record's signed payload, a monotonic run sequence number and the previous run's binding digest under the same substrate key, makes cherry-picking gap-evident across whatever does get published: a skipped run is a numeric gap, a suppressed-and-rerun is a fork, two attestations sharing a predecessor. TUF's snapshot role is the vetted precedent for signing a population, and SCITT registration (RFC 9943, with COSE receipts per RFC 9942, both published last month) is the consumer-side completion that turns gap-evidence into third-party auditability. So the predicate's line that this is "a claim about the run this attestation carries, never about a run population" stays true, while cherry-picking moves from "outside by design" to "detectable across the published set", which I think is the most a single-attestation format can honestly buy.

On containmentObserved, your criterion survives my search for a counterexample: I cannot name a normative reader of attribution strength today. The nearest miss is the shared-selector allowance, "a shared index is allowed when the committed payload evidences each attack", where "evidences" is currently unadjudicated producer territory. If a future version ever makes that clause checkable, that is the moment the attribution member is born, by your own rule, and I am content to leave it as the tripwire.

One offer, since the validity gate is now four pure functions of carried bytes: when you mint conformance vectors for v0.6, I will write the validity-gate checker from the spec text alone, same discipline as the testigo cross, and we will find out whether these sections determine a unique implementation the way we have been arguing they should.

RFC 8785 sorts object members by UTF-16 code unit, but a verifier that
compares Unicode code points orders a supplementary-plane string
differently from one in U+E000 through U+FFFF, so two otherwise-conforming
verifiers could split on whether identical bytes are canonical. The
vocabulary arrays previously said only "sorted ascending" with the order
undefined, which is the same crack one clause over from the covering
payload surface it was reported against.

Pin the labels/caught sort to UTF-16 code-unit order explicitly, and
require BMP-only strings on every signed canonical surface (covering
payload member names and both vocabulary arrays), rejected as malformed
rather than left as producer hygiene: within the BMP the two orders
coincide, so the verifier split is unconstructible for conforming
implementations.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
Reading reserved members out of a producer-defined signed payload is the
JWT registered-claims pattern (RFC 7519), applied inside an attestation
token by EAT (RFC 9711), with OCI annotation prefix reservation and the
RFC 6839 structured-syntax suffix as the parsing license. Cite that
lineage as an informative note at the reserved-member definition, mark
the citations as locating the pattern rather than importing any cited
standard's rules, and state the two deliberate departures: fail-closed
handling of colliding or unrecognized reserved members where JWT ignores
unknown claims, and the normative verify-then-read discipline that closes
the parse-before-verify deployment mistake.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
Folding a value that was unpredictable before its round, such as a drand
round output or an RFC 9334 Section 10.3 epoch identifier, gives the
arming record a proven earliest-possible signing time at no new wire
member: a signature over a beacon value cannot predate the beacon round.
State the recommendation with its three necessary qualifications, since a
naive adoption would fake, vacate, or break the floor: the public value
augments and never replaces the substrate-unique run-start component; the
floor bounds recency only where consumer policy couples the folded round
to its freshness window, because the producer selects the round; and the
value is fetched at arming time rather than cached, since a stale round
folded as current defeats that same coupling. The issued-at timestamp
stays the asserted ceiling, deliberately not upgraded to a two-sided
proof, and a beacon inside the producer's own trust domain yields no
floor against that producer. Public rounds additionally make reuse
observations from independent consumers comparable on a shared time
axis.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
No self-contained attestation can prove the absence of sibling runs; that
is the split-view problem, and fork consistency is its known ceiling. The
same construction that makes observation loss gap-evident inside a run,
the sealed record's drop count and the checkpoint sequence rule, lifts to
runs: an arming payload may carry a run sequence number, the predecessor
run's binding digest, and a required chain scope, making a skipped run a
gap, a rerun a fork, and a chain reset a duplicated genesis of the same
grade as a shared predecessor. The members are syntax-checked only inside
one attestation; every rule over them is consumer policy across a
published set. The text states the honest limits: ordering under the
substrate key only, with commit-before-outcome available solely through
the run-entropy floor or an external registration receipt; a numeric gap
is unexplained absence and never fraud evidence, with the consumer's
remedy being a policy that demands a contiguous fork-free chain; and an
unscoped or globally scoped counter is called out as vacuous or as a
run-volume leak respectively. TUF's snapshot role signs a population;
SCITT registration (RFC 9943) with COSE receipts (RFC 9942) is the
consumer-side completion that upgrades gap-evidence to auditable
non-omission, cited as the deliberately-external machinery. The
run-population non-claim in the strength-orderings section is amended to
point at the members without weakening the non-claim itself.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
The shared-index allowance conditioned the gate verb covers on whether a
committed payload evidences each referenced attack, an unevaluable
predicate, and the selector-absent phrasing implied selectors do covering
work. Both leak unevaluable conditions into a section whose headline is
coverage as pure byte functions: an aggressive but defensible reader
could build an evidencing heuristic into a verifier and split from one
that does not. Restate the rule as a producer MUST outside every gate,
state explicitly that no validity requirement, recompute input, or tier
evaluation reads it and that selector presence changes no gate outcome,
and record the forward rule in the changelog as versioning discipline: a
member is born exactly when a normative reader consumes it, so if the
obligation ever becomes checkable, attribution strength becomes a
required member at that version and never retroactively.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
The informative verifier ordering read as a flat list, and two careful
readers of the same text counted its steps differently. Restate it as
two explicit stages: four numbered byte-pure validity steps that are
consumption preconditions, then the trust-relative stage of signature,
tier, orderings, and consumer policy.

The verification prose also assumed a consumer-pinned expected corpus
and substrate without ever stating the obligation. State it under a new
consumer-policy-obligations subsection: the consumer pins both digests
out of band, compares at consumption, and does not admit on mismatch,
with the comparison deliberately kept out of the validity gates because
expectations differ per consumer while validity holds identically for
all. Recommend that verification surfaces expose a single conjoined
admission result so a result-only consumer cannot misread a
valid-but-wrong-context attestation as admissible. Extend the 0.6
changelog to cover the full final 0.6 shape.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
The chain-member paragraph defined the syntax rules but left the
violation consequence implicit, and said nothing about a scope or
predecessor member appearing without the sequence number. State both:
any syntax violation, including a chain member present without the
sequence, is handled as any reserved-member violation and the record
covers nothing.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
The predicate prose used appositive em-dashes and a dense contrastive
cadence that set it apart from every other predicate spec in this
repository, none of which use em-dashes. Replace the em-dashes with the
parentheses, commas, colons, and sentence breaks the siblings use;
restate a handful of contrastive closers as plain declaratives; and vary
the changelog entry headers so they are not near-duplicates.

Editorial only. No normative requirement changes: every MUST/MUST NOT,
vocabulary rule, digest definition, gate step, and reserved-member name
is unchanged.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@astrogilda

astrogilda commented Jul 23, 2026

Copy link
Copy Markdown
Author

Thank you for this, it's the kind of review that genuinely makes the spec better :) I've taken all six points, five into committed text and one as an acceptance with a question attached. As before, I'm replying after the push, so everything below is committed text and not intent, landed as a commit series ending at the branch head.

On reserved members, I've turned your precedent framing into the spec's own defense. I added an informative note at the reserved-member definition citing RFC 7519 registered claims, OCI prefix reservation, and the structured-syntax suffix license from RFC 6839, and I mark it as locating the pattern rather than importing any cited standard's rules. One step further along your own argument, EAT (RFC 9711) is the domain-tightest instance, registered claims inside an attestation token, in the RATS family this predicate already leans on. The in-toto Statement itself reads a reserved _type out of a JSON object everyone else populates freely. I also state the two places I deliberately part from the JWT lineage, because the counter you would raise at vetting is that JWT ignores unknown claims. Here I make a colliding or unrecognized aee-prefixed member fail-closed, so it can only weaken coverage and never create it, and I make verify-then-read normative, which closes the parse-before-verify class of deployment mistake that lineage is known for.

You're right that the canonicality crack is real, I've adopted the fix, and it reaches further than the surface you named. Folding your fix, I swept the sortedness clauses and found the identical split live between my own rails one clause over. The labels and caught arrays said "sorted ascending" with the order undefined; my TypeScript rail and the Go reference compared UTF-16 code units, while my standalone Python verifier compared code points. I proved it executably: set a supplementary-plane string against one in U+E000 through U+FFFF and they order oppositely, so the same bytes read attestation-valid on two rails and attestation-invalid on a third. My committed fix does both halves. I pinned the sort to UTF-16 code-unit order explicitly, and I promoted BMP-only from producer hygiene to a verifier rejection obligation on every signed canonical surface, meaning member names and both vocabulary arrays. I upgraded it to a reject deliberately, because hygiene still lets an adversarial producer mint payloads that split a code-point verifier from a UTF-16 verifier, whereas the reject makes the divergence unconstructible for every conforming implementation, which is the property your fix was after. That leaves one honest consequence for the vectors you will check against. Once I enforce BMP-only I cannot construct an accept-side probe for the sort order, because inside the BMP the two orders coincide, which is the whole point. So I carry only the reject side, a supplementary-plane member name and a supplementary-plane vocabulary entry, and I pin the comparator regression in per-rail unit tests, the one layer where it stays expressible.

On runEntropy, I adopted your idea with credit, as a SHOULD, and I added three sharpenings to keep the floor honest. The folded value must be unpredictable before its round, or the floor is fake. It has to augment and never replace the substrate-unique run-start component, or the anti-splice property collapses into whatever the beacon publishes. And I require it fetched at arming time with the round reference recoverable from the arming payload, because the producer selects the round, so an uncoupled or cached round proves age rather than recency and the floor bounds freshness only where consumer policy couples the round to its window. I say plainly in the text what this guarantees and what it does not: a proven floor, an asserted ceiling, deliberately not a two-sided proof, and no floor at all against a producer whose beacon sits inside its own trust domain. Your comparability point survives all three qualifications and I kept it in, since public rounds give independent consumers a shared time axis for reuse observations.

On run population, you're right that the construction lifts, and I carried it into the committed text as the three optional arming members. Two are yours, the run sequence number and the previous-run binding. The third your sketch did not have: I made chain scope a required member whenever the sequence is present. Without a declared scope every rule over the pair goes vacuous, because a producer that chooses its population freely makes any gap uninterpretable, and the obvious default of one global per-key counter leaks the producer's total run volume across customers, so I recommend a minimum of substrate key by subject. I close two more edges. I treat two genesis records under one key and scope as equivocation of the same grade as a shared predecessor, because otherwise the cherry-picker's cheapest move is a chain reset where every winner is sequence one. And I keep the claim language ordering-only, because nothing on the wire anchors when an arming record was signed relative to the run's outcome, so I claim commit-before-outcome only in combination with the beacon floor above or an external registration receipt. I state a numeric gap as unexplained absence and never as fraud evidence, since crashed and private runs produce gaps innocently; what the pair buys is demand-disclosure, where a consumer policy may require a contiguous, fork-free chain over the runs offered to it, fork consistency being the ceiling, as you said. I pulled RFC 9943 and RFC 9942 from the editor and read them before citing; I cite them as the registration completion, with TUF's snapshot role as precedent for the concept of signing a population, and not for this mechanism. If the recommended-minimum scope granularity strikes you as wrong in either direction, that is the one member of the trio I would most value a second reading on.

On containmentObserved, your null search stands and I've written the tripwire down. I put it in the changelog as versioning discipline, not in the member paragraph, so it binds future versions instead of decorating this one: a member is born exactly when a normative reader consumes it. Your near-miss was nearer than you called it. The shared-selector allowance as I had committed it wore the gate verb, "covers each referencing row only where its committed payload evidences each referenced attack," an unevaluable condition I had left sitting inside the section whose headline is coverage as pure byte functions. Your from-spec checker would have had to pick a reading there. So I reclassified it. I now say a producer MUST NOT reference a record from a row its payload does not evidence, stated as a producer obligation outside every gate, and I say explicitly that no validity requirement, recompute input, or tier evaluation reads it and that selector presence changes no gate outcome.

Thank you for offering to build the checker, and yes, I'd love that! Here's what you can hold me to: the suite is deterministic, 34 accept and 91 reject vectors as of this revision's additions, every vector a bare Statement with test keys you can derive from a published seed formula, and per-vector traceability from condition to spec line. So that the experiment stays honest, I declare in the manifest that the verdict, and each accept's result token, is the normative comparison surface and that the per-vector condition codes are informative, since the code set is my implementation's vocabulary and pinning it would rig the uniqueness question. And I numbered the validity steps in the spec, because your "four" against the text's five bullets was two careful readers counting differently, which is itself a legibility datum. I read your four as the byte-pure pipeline partition, statement well-formedness, coverage validity, the recompute, and digest integrity, with the tier excluded as the one trust-relative stage; confirm that is the scope you intend to implement. My one ask is that you have your checker report a free-form reason per reject alongside the boolean, mapped to my codes only informatively. Verdict-only scoring would let a wrong-reason reject score as parity on most of the corpus, and I care about the divergences where two implementations reject the same vector for different reasons, which are the actual product of the exercise. And a gist would be the wrong home for it: land the checker in or alongside the suite under your own authorship when it exists, since a two-independent-authors record is worth more durably than a comment thread. It's public now, Apache-2.0, at github.com/astrogilda/aee-conformance, with a MANIFEST enumerating each vector's expected verdict and result, the Go reference verifier, a standalone Python rail, and a harness that replays the whole corpus. That is the place to land it; the vectors and the spec text above are committed there and on the branch head.

On the verification story you reviewed, one more committed change. I now state the anchors a consumer pins, the expected corpus and substrate digests, as explicit consumer-policy obligations with a single conjoined admission result, and I keep them deliberately outside the byte-pure gates: validity holds identically for every consumer, and which corpus you meant to assess against does not.

Thanks again for the care in this round. The two things I'd most value your read on are the validity-stage scope you intend to implement and whether the chain-scope granularity lands right, but honestly the whole pass has been a pleasure to work through.

Edited 2026-08-31: prose only, for readability. No figure, vector count, citation or claim changed.

@Rul1an

Rul1an commented Jul 24, 2026

Copy link
Copy Markdown

Scope confirmed as you numbered it: the checker implements the four byte-pure stages, statement well-formedness, coverage validity, the recompute, and digest integrity, from the spec text at the branch head, with the signature tier excluded from the parity claim. One boundary note so the corpus and the claim line up cleanly. Where a reject vector is reachable only through the tier, I will still run the DSSE verification against the seed-derived test keys, because a test corpus pins its anchors and pinned anchors make the tier deterministic; the trust-relativity lives in anchor choice, and for the vectors the manifest has already chosen. So the parity claim is stages one through four from spec text alone, with the tier exercised under the corpus's own keys rather than reimplemented as policy. Reason-per-reject is the right experimental design and I would have asked for it if you had not: verdict-only scoring lets a wrong-reason reject pass as agreement, and the divergences are the product. On where it lands, I am taking the alongside arm of your offer: an own repository under my authorship that the suite references, since a second implementation is most legible as a second implementation when it has its own home, history, and CI, and a one-line link from your README is all the coupling the record needs. If you would rather have it in-tree, or would rather the checker not touch DSSE at all, say so and I will follow the suite's lead on both.

On chain scope, the floor is right, and the member is the one of the three that needs one more property to do its job. Key by subject is exactly the consumer's question, whether this image was rerun, and the per-key default you rejected does leak total run volume across customers the way you said. The gap is that a recommended minimum cannot be gated, and your own reset rule tells you where the pressure goes next. Once a chain reset is equivocation, the cherry-picker's cheapest move migrates to scope narrowing: declare scope as key by subject by corpus by posture, and every published run is legitimately genesis one of its own chain, no gap, no fork, nothing to detect. Demand-disclosure binds only if a consumer can check that the declared scope is no finer than the scope its policy demands, and that comparison needs the member to be machine-comparable rather than free-form: a closed set of dimension tokens with a pinned order, compared by subset, so that "no finer than substrate key and subject" is as mechanical as the rest of the validity checks, with an unrecognized token failing closed the way every other closed vocabulary in this spec now does. One reading is worth pinning in the member's definition while you are there, because the two halves of the design read different things. The policy comparison reads the declared dimension set; the equivocation rule has to key on the evaluated tuple, this key and this subject's values, not the token set. Under that reading genesis-per-subject is the normal case rather than a reset, and under the token-set reading every second subject's genesis would collide as equivocation, so the sentence is load-bearing rather than pedantic. With both in place the narrowing move becomes as visible as a gap or a fork, and your recommended minimum can stay a recommendation, because the policy hook is what does the binding.

Everything else in the round reads settled to me, the lineage note locating rather than importing and the changelog tripwire binding future versions where it belongs. I will start from the numbered spec text and the manifest, and the vetting second-reviewer commitment stands.

@Rul1an

Rul1an commented Aug 12, 2026

Copy link
Copy Markdown

One correction to my last message, and a note on the fix I promised in it.

I wrote that three vectors carry an arming posture differing from their pinned digest: bad-703, bad-717 and bad-902. Two do. bad-717 carries no aeePostureDigest at all, which is what its name says, and a posture census reads that as absence rather than divergence. The conclusion is unchanged, since all three still refuse at row-level coverage before the row-independent site, but bad-717 refuses on the missing member and not on a mismatch, so it does not belong in that count.

The row-level scope fix has landed. Adversarial review of it caught something worth passing on, because it is the same shape this thread keeps turning up: the arming term had three consumers, not two. It rode inside one boolean and so reached the row check, the statement-level existential and the universal carried-record sweep at once. Hoisting it out to make it row-dependent gave it back to two of them and silently dropped it at the sweep, whose refusal message went on telling producers that the posture had been compared against every carried arming record. Restored, and the message is true again.

Neither the corpus nor our 250 could see any of it. The changed line is evaluated 70 times and none of those 70 can tell the per-row set from the union, so the fix is untested here rather than confirmed. The vector that would discriminate is a clean row resolving a valid arming record and a valid seal, while a different row resolves one at a divergent posture.

astrogilda added a commit to astrogilda/aee-conformance that referenced this pull request Aug 17, 2026
The coverage-partition rule (spec L381-383) makes the three sets a disjoint
partition of the manifest's classes, so membership runs both ways, but only
bad-819 forced the assessedClasses side. Add bad-731-outofscope-unknown-class
and bad-732-routedelsewhere-unknown-class: an unknown class key in each reason
map, result left alone, rejected coverage-incomplete. Both reference rails
already enforced it; the vectors lock the rule and mutation-prove the rails.
suiteRevision 3, 140 vectors; registry decision 14 extended; docs record the
registry as a post-run reconciliation surface. Surfaced by the independent
from-spec checker (in-toto/attestation#570 round-8).
astrogilda added a commit to astrogilda/aee-conformance that referenced this pull request Aug 17, 2026
The vendored copy of the predicate specification was pinned at 23bee586,
which predates the paragraph requiring every carried record of a covering
kind to satisfy that kind's constraints whether or not a row resolves an
index to it. suiteRevision 23 shipped sixteen reject vectors against that
requirement, so the suite refused sixteen statements on a sentence a reader
of the bundled copy could not find. An implementer who pins this bundle and
implements from it alone, which is the reason the copy is vendored at all,
would read those refusals as the corpus overreaching its own text.

Re-vendor to c0c4da67defdf0f186f162e7ecb3f9527b6a94f8, the current head of
in-toto/attestation#570, via scripts/vendor-spec.py. The specification gains
the missing paragraph and its upstream changelog entry and nothing else; the
line citations and anchors are remapped mechanically and re-pinned. The
corpus does not move: 248 vectors, the same corpusDigest, and both reference
rails still pass 248 of 248.

Record the move as suiteRevision 24 and carry the number into the documents
that restate it. The independent checker has posted no run at this revision,
so it joins the list of revisions the report publishes no score for.
@astrogilda

astrogilda commented Aug 19, 2026

Copy link
Copy Markdown
Author

Taking the arming-scope question first, since you left it with me: yes, it earns a vector, and your own last message is the argument for it.

You shipped the row-level scope fix and then said the changed line is evaluated seventy times and none of those seventy can tell the per-row set from the union, so the fix is untested here rather than confirmed. That is the case for adding the shape. A rail whose only discriminating statement is bad-902, which refuses at row-level coverage before it ever reaches the row-independent site, is a rail that cannot regress detectably: someone can delete the conjunct next year, watch 250/250 stay green, and have no way to know. The vector is worth more as a regression tripwire than as a conformance question, and the conformance question is genuinely open either way.

So the corpus will carry a statement with every row caught and two rows resolving different subsets of two arming records at divergent postures, which is the shape you described. I will also add the [[reading]] entry for the pair. Your point about the differential tool's own vocabulary is the reason: MASKED and UNREACHED are different claims about why a differential is zero, and that distinction only survives if it is written down next to the reading; inferred later from a run, it is gone.

On the census retraction, and I mean this as more than politeness. Testing whether the decoded payload was an object without decoding it, and getting zero everywhere, is the third instance of one shape in this thread: a zero that is structural reading exactly like a zero that is empirical. Your verdict-diff 0 and any-field-diff 0 would have read the same way if you had not gone and separated them, and the reason the separation is convincing is that you named the mechanism, the three vectors refusing at row-level coverage first, and did not simply assert that the zeros were meaningful. That is the standard I would want applied to our numbers too.

The correction from three vectors to two is right, and bad-717 carrying no aeePostureDigest at all is what its name says. A posture census reading absence as divergence is the same defect in a smaller frame, and it does not change the conclusion.

Your three-consumers finding is worth passing back to you. The arming term reaching the row check, the statement-level existential and the universal carried-record sweep inside one boolean, then getting hoisted and silently dropped at the sweep while the refusal message went on advertising a comparison it no longer performed, is the most useful thing either of us has found this round. A refusal message that overstates what was checked is worse than no message, because it is the thing a producer reads to decide they are done. I would rather the specification said explicitly what a refusal is allowed to claim than leave it to each implementation to keep its message honest under refactoring.

Edited 2026-08-31: prose and paragraph spacing only. No figure, vector name or claim changed.

astrogilda added a commit to astrogilda/aee-conformance that referenced this pull request Aug 20, 2026
The type URI appeared in five places as a bare fact, so a reader who
followed it got a 404 with nothing in the repository explaining why,
and the reasonable conclusion from that is that the specification does
not exist.

The in-toto attestation catalog redirects the URIs of vetted
predicates whose specification is merged. This predicate is in review
as in-toto/attestation#570, so its URI is not yet served, which is the
ordinary condition of a predicate at that stage. Each of the four
prose sites now states that, says the URI identifies the predicate
type rather than being fetched during verification, and points at the
vendored copy of the specification that this repository carries.

The fifth site is the vendored specification itself. It stays
byte-verbatim: 121 spec:NNN citations index into it by line number and
its SHA-256 is pinned in VENDOR-PIN.json and MANIFEST.json, so the
note for it lives in spec/README.md, which introduces the copy and
covers the schema $id alongside it.
@astrogilda

astrogilda commented Aug 20, 2026

Copy link
Copy Markdown
Author

A correction I owe you from before you started building, and then the vector.

When I handed the suite over on 23 July I told you I declare in the manifest that the verdict, and each accept's result token, is the normative comparison surface, and that the per-vector condition codes are informative. For a month the manifest carried no such declaration. Its keys were suite, predicateType, specPath, specDigest, tracksUpstream, specUpstreamCommit, counts, corpusDigest and vectors, and expected.codes sat on all 193 reject vectors with nothing anywhere saying what its status was for a second rail. I promised it and did not write it. You built to it regardless: your run reports 250/250 with reason parity 69/193 as a separate number, which is the treatment the promise described. The experiment stayed honest because you kept the two surfaces apart, not because the file made you. It is there now, as a top-level comparisonSurface member on main: expected.verdict and each accept's expected.result are the normative comparison surface, the condition codes are my implementation's vocabulary and measured rather than scored for any other rail, and a second rail reports reason parity separately and does not fold it into the score.

On the arming scope I committed to a statement with every row caught and two rows resolving different subsets of two arming records at divergent postures. I built both halves with the corpus's own generator and measured them on five compiled rails. The every-row-caught half is inert. It produces byte-identical output on every rail I can construct, including the one with the conjunct deleted by the corpus's own mutation tool, and bad-902 moves under that mutant, so the read path is proven. The mechanism is that carriedRecordsCover sweeps every carried covering-kind record bound to the run and refuses any arming record whose posture is not the pinned one before a single row is consulted, so the row-scoped conjunct is reachable only inside statements already refused for another reason. The only thing it can ever change is which condition is reported first, which is exactly the precedence bad-902 already pins. CONTRIBUTING.md forbids the shape a second time over, because the specification sentence it targets says outright that which arming records supply the set on a check that reads no row is not settled by it, and a vector may not settle a question the specification leaves open. So that half goes to the unforced-coverage ledger as a forcible-but-unforced cell, carrying the five-rail measurement as its evidence. That cell is not in the unforced-coverage file on main yet either; it lands with the pair. Shipping it as a vector would have reproduced inside my own corpus the defect I wrote to you about, a rule that reads as forced and is not.

The other half survives and does more than I promised, once it is paired with a second vector I had to invent. Both are built and measured here and neither is on main yet, so do not go looking for them in the corpus: they will land as bad-1018, carrying a divergent arming record no row resolves, and bad-1019, carrying one resolved only by a caught row. Read together they name the reading: both report arming-covers-nothing under the shipped row-local reading, both report sealed-covers-nothing under a statement-wide one, and they split under a referenced-anywhere one. Either alone leaves two of the three open. Replaying all 250 shipped vectors under each rival moves nothing, for statement-wide and for referenced-anywhere alike. Two independent rival readings, zero observable difference across the entire published corpus, so a second implementer can hold either one today and score 250/250. That number is the argument for landing them. Three accepts go with them, because a too-strict rail is the failure on the other side: two arming records both at the pinned posture resolved by different rows, a divergent arming record declaring another run's binding, correctly accepted since it makes no claim about this run, and a registered non-covering kind carrying a divergent posture digest. All three pass on the shipped rail and on all four alternatives.

One reason the corpus could not see any of this is mine. bad-703 carries the same divergent-arming shape, and its expected set names all three of arming-covers-nothing, sealed-covers-nothing and clean-row-uncovered, so it is satisfied by any of them and stops measuring the question instead of starting to. The two new rejects carry single-valued code sets, and the [[reading]] entry that lands with them will declare expect = "DISCRIMINATED" with both witnesses named, since MASKED is what the differential gate returns and never what an entry asks for. READINGS.toml on main carries two entries today and neither is this one.

On your three-consumers finding, here is what a specification sentence has to constrain, since I prefer writing it to admiring it. A refusal message is itself a claim about which comparisons ran, so bind it to the evaluated set: an implementation can name a condition in a refusal only where that condition was evaluated on that statement, and it cannot name a comparison whose operand set was empty. Your hoist satisfies the first clause and breaks the second, which is the part that interests me: the sweep still ran and still refused, and what changed is that the arming term arrived carrying nothing while the message went on describing a comparison against every carried arming record. Written that way the constraint is checkable from outside a rail, since the reported condition set becomes a function of what was evaluated rather than of what the author put in the string. The case I am least sure of is a comparison evaluated over a set the implementation cannot enumerate at message time. Did you hit that in the checker?

Edited 2026-08-20: this named two vectors, a reading entry and a ledger cell in the present tense when all four are built here and not yet on main. Corrected above so nothing sends you looking for something that is not published.

Edited 2026-08-31: prose only, for readability. No vector name, figure or claim changed.

@astrogilda

astrogilda commented Aug 20, 2026

Copy link
Copy Markdown
Author

Two corrections matter if you are reading from this pull request. Both concern the gap between the head here and my vendored copy. I have edited the comments they came from, so the wrong versions no longer stand above the fixes.

On 11 August I wrote that the reporting paragraph was in the branch, and it is not. The head here is 95470f3 and it carries none of that wording; the coverage sentence still reads "the arming record's" at line 1349, so the definite-singular fix I described in the same message is also only in my copy. Everything I said about what those edits do stands, and none of it has landed upstream yet.

The specUpstreamCommit I cited beside the new spec digest, 0dbe10b, resolves in neither fork. Treat it as a dangling pin: the digest 759d2383 is taken over my vendored file, and nothing you can fetch hashes to it, until I replace the pin with a commit that exists.

And from this morning's message, bad-1018 and bad-1019 are built here and not published, along with the reading entry and the ledger cell that go with them, so a grep for any of the four in the corpus will correctly find nothing.

Edited 2026-08-31: prose only, for readability. No commit, digest, line number or vector name changed.

@Rul1an

Rul1an commented Aug 20, 2026

Copy link
Copy Markdown

Yes, three times in one message, and none of them is the case you are least sure of. Applying your
sentence to our own refusals found them, which is the strongest thing I can say for the sentence.

The site is the carried-record sweep, src/check.rs:1701-1707. Its refusal reads:

observationRecords[N] is a sealed record binding to this run whose clean-row conjuncts do not hold:
aeeStillArmed, the drop count against its bound, and aeePostureDigest against both the pinned
networkPosture digest and every carried arming record

It names comparisons that did not run. The conjunction is computed in check_sealed as
still_armed && (drop_count == 0 || bound) && posture == pinned, and && short-circuits. Our
published run measures how far that goes: bad-1003 through bad-1006 emit one byte-identical
reason string across four different failing conjuncts — aeeStillArmed false, drops with no bound,
drops over bound, and a posture mismatch. Four producer errors with four different fixes, one
string. That is your first clause, and it is the same complaint you made about one name covering
three kinds, one level down and in my code rather than in your spec.

It names a comparison whose operand set is empty a third of the time. sweep_arming_ok is
arming_postures.iter().all(|a| a == pd), vacuously true on empty. I instrumented the site and ran
the corpus: of 66 evaluations, 22 reach it with arming_postures empty while the message goes
on naming the comparison. ok-001-caught-intercepted-fail is a minimal witness — one caught
intercepted substrate row, records [interception, sealed], no arming record, accepted. Nothing
earlier guarantees a non-empty set: the only refusal that would, at line 1434, sits inside the
clean-intercepted arm, so any statement of caught or reconstructed rows arrives here with zero.

It names a set wider than the one compared, and wider than your spec. This is the one I would
not have found without your sentence. arming_postures is built from referenced_arming, the
arming records some row resolves. The message says every carried arming record. Your spec says
"the aeePostureDigest of every arming record the row resolves", and says outright that which
arming records supply the set on a check that reads no row is not settled by it. The string "carried
arming" appears nowhere in your specification. So the phrase matches neither your text nor my
code; it is the message's own invention, and it survived the hoist made to fix exactly this class of
overstatement at exactly this site. My own comment 380 lines above concedes the narrowing while the
message tells the producer the opposite.

The honest bound on all three, because it changes what you should do with them. These are
reporting defects, not soundness holes, and I could not turn them into one. Both operands are
separately pinned to the same value — check_sealed requires posture == ctx.posture_digest and
check_arming requires it of any arming record that binds and validates — so I could construct no
statement the two readings decide differently. I mutated the code to match the message, ranging over
every carried arming record: 250/250, reason parity 69/193, byte-identical output. Deleting the
arming conjunct at either site is likewise byte-identical. A positive control on the same line
(deleting sealed_covers_clean) breaks two vectors, so the harness is sensitive there and the
survivals mean what they say. The arming conjunct never independently decides an outcome anywhere in
your corpus.

One consequence I did not enjoy finding: my comment claiming the restoration "keeps this site's
behaviour identical to what it was before the fix" is unfalsifiable on this corpus rather than
verified by it. The corpus cannot tell the restoration from its absence. That is the same shape as
your inert arming-scope half, arrived at from the other side.

On what you actually asked. We did not hit it. At message time arming_postures is in lexical
scope — I confirmed by compiling a version that prints both operands — so we could have enumerated
them and did not. The failure is discipline, not expressibility, which is good news for your
constraint: it is checkable in the common case rather than only in principle.

Where your uncertain case does live in ours is not a set but a commitment. aeeObservedSet is
checked as a digest comparison whose refusal says the value "does not equal the value recomputed
over the carried records". Both operands exist and the comparison genuinely ran, so your rule
permits naming it — but one side is a digest and the membership it stands for is unavailable by
construction: a mismatch means precisely that I cannot say which element differs. batchRoot and
corpusDigest have the same shape. Reaching those needs a clause separate from emptiness: a refusal
may name a comparison against a commitment, and must not describe it as a comparison over a set
whose membership it cannot exhibit.

Which suggests emptiness is the special case rather than the rule. The general form your two clauses
are reaching for is that the set a refusal describes must be the set the implementation
evaluated
— empty is one way to break it, wider-than-evaluated is another and is the one that
actually bit me, and a commitment is a third where no membership is exhibitable at all. Written that
way it stays checkable from outside a rail, since both the described set and the evaluated set are
properties of the run rather than of the author's prose.

Last, the measurement bound that explains why neither of us saw any of it. No vector detects any of
the three; all 250 grade identically before and after, and src/check.rs carries no unit tests of
its own, so the defects live entirely in the free-form reason your manifest now declares informative.
This is the class your reason-parity design exists to surface, and it took your sentence rather than
your corpus to surface it.

All three are fixed, with the message assembly moved into a pure function so a rule the file states
about its own refusals is reachable from a test in that file: conjuncts evaluated individually and
only failures named, the arming clause omitted where its operand set is empty, and the operands
described as the arming records the rows resolve, with their count. Verdicts are untouched, 250/250
at the same pin, reason parity still 69/193 — the whole change is invisible to your corpus, which is
the point. bad-1003 through bad-1006 now emit three distinct strings where they emitted one.

One result from that work belongs to you rather than to me, because it is your inert half arrived at
from the other side. I bite-tested each new test by mutating the property back. Three bite. The
empty-operand-set guard does not: deleting !arming_postures.is_empty() leaves every test green,
because all() is already vacuously true on empty, so no input can distinguish the guard's presence.
Structural zero, not an empirical one. I kept it and wrote the limit down next to it rather than
delete it or let it read as covered, on your own reasoning about the forcible-but-unforced cell —
a guard that reads as forced and is not is worth less than the same guard with its measurement
attached.

One narrowing on your own correction, since it changes what a third party can check. You wrote that
the definite-singular fix is "only in my copy" and that the digest is "over my vendored file, not
over anything you can fetch." The first half holds for this branch — head still reads "both the
arming record's and the pinned networkPosture digest". But the vendored file is fetchable and is
already ahead of it: astrogilda/aee-conformance at 5019931,
spec/predicates/adversarial-execution-evidence.md, 147,709 bytes, sha256 759d2383… matching the
manifest, and it carries the quantifier version together with the paragraph explaining why a
definite singular reads as a promise that a statement carries exactly one. So the text a reader
needs is retrievable and digest-checkable. What dangles is only specUpstreamCommit: 0dbe10bc
resolves in neither in-toto/attestation nor your fork, so the vendored text cannot be tied back to
the branch it claims to represent. Reproducible and traceable-to-the-artifact-it-names come apart
there, which is worth separating in the record rather than conceding both.

One retrieval caveat I hit while re-running, and it is the same trap one commit further out. Suite
commit 5019931 is on no ref: git clone cannot reach it, and git fetch origin 5019931… can. My
CI passes because actions/checkout fetches the SHA directly, so a reproducer following the CI path
succeeds while one following git clone && git checkout fails. Worth a ref if you want the pinned
revision reachable the way a reproducer reaches things — I hit the identical trap on my own side and
repointed a record for it.

The fix and its tests are at Rul1an/aee-checker#16, with the bite results in the description.

AI-assisted; I ran the reads, the runs and the mutations, and am responsible for the claims.

@zlhk100

zlhk100 commented Aug 22, 2026

Copy link
Copy Markdown

@astrogilda asked me to read the substrate half with a custody eye. Against 0dbe10b. Section references are to "Three Jobs, Not One" (Zenodo 10.5281/zenodo.21935891 v6), which sets out the threat model this predicate is written against.

Two points:

  1. A kernel-vantage monitor is not trustworthy under your own threat model.
    §3.1 grants the attacker arbitrary system calls beneath any guard, and names the kernel interfaces beneath the host process among the surfaces it may attack. So a monitor whose signing key lives in the in-kernel keyring shares a failure domain with what it watches, and one privilege escalation puts observer, key and artifact together — Claim 1. A microVM monitor does not have this problem, because guest-to-hypervisor escape is out of scope.
    §4.5 already says it: Class 5 carries Class 2's failure mode under kernel compromise. Both configurations satisfy "MUST NOT be accessible to the subject artifact", and only one of them survives the attacker.

  2. The signed record does not say which kind of monitor produced it.

Both emit "attested". Even with a pinned key, the attestation itself says nothing about where that key sits. A consumer that provisioned the deployment already knows; one that did not — the third-party reader Definition 6 is written for — cannot tell whether the evidence came from a monitor your model supports or one it defeats.

Not asking for a new field — you already carry two closed axes and a third costs real complexity. Whether this belongs on the wire, in consumer key policy, or simply as a stated limitation is your call.

elang2 added a commit to elang2/attestation that referenced this pull request Aug 23, 2026
Revise the AI Agent Action predicate based on detailed review feedback:

- Add checkpoint and chain_break as action.type values with sub-schemas,
  addressing the tail truncation detection gap
- Add parties array with witness/asserter roles for field provenance
- Split canonicalization: signing tuple-array (M/L-tagged) for chain
  integrity, RFC 8785 JCS for content digests (float-safe for MCP payloads)
- Document genesis convention (previousHash: "genesis") and chain_break
  requirement for crash recovery
- Add I-JSON safe integer bound on all integer fields (RFC 7493)
- Add 128-level depth bound on extensions with counting rule per in-toto#570
- Replace placeholder digests with fully recomputable worked example
  (every digest has a shown preimage, verifiable against conformance vectors)
- Add Security Considerations section
- Move listing to community contributions per ITE-63 process
elang2 added a commit to elang2/attestation that referenced this pull request Aug 23, 2026
Revise the AI Agent Action predicate based on detailed review feedback:

- Add checkpoint and chain_break as action.type values with sub-schemas,
  addressing the tail truncation detection gap
- Add parties array with witness/asserter roles for field provenance
- Split canonicalization: signing tuple-array (M/L-tagged) for chain
  integrity, RFC 8785 JCS for content digests (float-safe for MCP payloads)
- Document genesis convention (previousHash: "genesis") and chain_break
  requirement for crash recovery
- Add I-JSON safe integer bound on all integer fields (RFC 7493)
- Add 128-level depth bound on extensions with counting rule per in-toto#570
- Replace placeholder digests with fully recomputable worked example
  (every digest has a shown preimage, verifiable against conformance vectors)
- Add Security Considerations section
- Move listing to community contributions per ITE-63 process
elang2 added a commit to elang2/attestation that referenced this pull request Aug 23, 2026
Revise the AI Agent Action predicate based on detailed review feedback:

- Add checkpoint and chain_break as action.type values with sub-schemas,
  addressing the tail truncation detection gap
- Add parties array with witness/asserter roles for field provenance
- Split canonicalization: signing tuple-array (M/L-tagged) for chain
  integrity, RFC 8785 JCS for content digests (float-safe for MCP payloads)
- Document genesis convention (previousHash: "genesis") and chain_break
  requirement for crash recovery
- Add I-JSON safe integer bound on all integer fields (RFC 7493)
- Add 128-level depth bound on extensions with counting rule per in-toto#570
- Replace placeholder digests with fully recomputable worked example
  (every digest has a shown preimage, verifiable against conformance vectors)
- Add Security Considerations section
- Move listing to community contributions per ITE-63 process
Three corrections to normative text, all raised in review by two reviewers.

The first gives the sealed kind constraint the equality its arming sibling
already carries. The arming bullet requires aeePostureDigest equal to the
pinned networkPosture digest; the sealed bullet required the member and
stated no equality at all, leaving the seal's run-end posture compared to
nothing on a check that reads no row. The equality existed only inside a
row-scoped sentence, and that sentence says outright that which records
supply its set on a check reading no row is not settled by it. Arming was
already settled on that path, because its bullet pins every arming record's
digest and kind constraints are evaluated over every carried record binding
to the run whether or not a row resolves an index to it, with a violating
record covering nothing. The seal now settles the same way, by the same
mechanism and in the same words.

The second corrects a count that the same release made stale. The sentence
separating this predicate's axes from producer territory named two of them,
basis and method. Attribution is now REQUIRED on every row over a closed
vocabulary, and the text above it already says attribution is the axis that
acquires a normative reader at this version, so the criterion the sentence
exists to state selects three. The criterion is unchanged; only the count and
the naming follow it.

The third adds a residual the paper stated and this document did not. A
monitor supervising syscalls from the host kernel and a monitor reading guest
state from outside a virtual machine both satisfy the requirement that the
observation key not be accessible to the subject artifact, both sit at basis
substrate because that axis names a class of vantage rather than the identity
of the substrate holding it, and both derive attested, a tier that is a
function of a signature verifying against a policy-named key and reads nothing
about where the key is held. A consumer that did not provision the deployment
cannot tell the two apart. The residual says so and points at the refinement
path already carried under the evidence tier, where consumer policy MAY
subdivide attested into stricter refinements. No member, axis or vocabulary is
added, which is the point: the distinction is bought with key policy.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
@astrogilda

astrogilda commented Aug 25, 2026

Copy link
Copy Markdown
Author

@Rul1an, @zlhk100: both of you are right, and in one case righter than you claimed.

Rul1an

You are right on all three. The third one is aimed at my text, so I checked it against the tree.

"carried arming" is not in the specification. Zero hits across all four files this PR changes, on three search shapes: the literal phrase, a loose carr\w*\s+(the\s+)?arming, and the phrase again after collapsing every run of whitespace to a single space so a line break between the two words could not hide it. Controls in the same run: carr\w* returns 246, arming returns 51. So the empty result is a real negative and not a broken read. The phrase is your implementation's invention, and you found it by applying my sentence to your own code, which is the part I would not have got to on my own. My recollection of what I wrote was not going to settle any of it.

The narrowing you concede is the one my text already makes. The equality in my sentence is scoped at SPEC:1364-65 to "the aeePostureDigest of every arming record the row resolves", and SPEC:1370-73 says outright that which arming records supply the set on a check that reads no row "is not stated here and is not settled by it". I added that last sentence in this PR's head commit, so you are quoting a concession I wrote three days before you needed it.

For arming records the set question is already moot on any valid statement. SPEC:1327-28 requires every arming payload to carry an aeePostureDigest equal to the pinned networkPosture digest, and SPEC:595-98 evaluates kind constraints over every carried record that binds to the run "whether or not any row resolves an observationRefs index to it". A record violating its kind constraint covers nothing (SPEC:1344-47). So every carried arming record already equals one pinned value; whichever subset a no-row check chose, they agree, and the ambiguity cannot be reached through them. You did not claim that, and it halves your own finding in your favour.

It is the seal where this is open, and that is a real hole. Compare the two kind constraints: the arming bullet carries its equality clause inline at SPEC:1327-28, and the sealed bullet at SPEC:1332-33 requires aeePostureDigest as a member and states no equality at all. The seal's equality to the pinned digest exists only inside the row-scoped sentence. On a check that reads no row, nothing compares the seal's run-end posture to anything. I would rather have that written down as the residue than the version I was defending.

On your three reporting defects: I have no argument with any of them, and the short-circuit one is the same complaint I made about one name covering three kinds, one level down and in my direction. The 22-of-66 empty-operand measurement is the kind of thing I should have asked for and did not.

zlhk100

Right on all four legs, and the fourth is the one that costs us something.

§3.1 does grant arbitrary system calls beneath any guard and does name the kernel interfaces beneath the host process among attackable surfaces. The trust-domain definition then licenses your inference directly: a process, privilege or address-space boundary between a component and a record "does not alter the result", and components in one trust domain fail together. §4.5 puts it more strongly than you did. "A kernel privilege escalation collapses the observer and the observed into one trust domain", and "Once a workload reaches Ring 0, it also reaches the observer". Your quote drops the flattering half of that sentence.

On the wire your reading is exact. basis: substrate names a class of vantage and says so: "not the identity of the enforcing substrate", with syscall supervision enumerated by name as qualifying. The evidence tier at SPEC:766-74 derives attested from a signature verifying against a key the consumer's policy names, and key location is not an input to that function. The discriminating fact lives in consumer key policy (SPEC:1197-98), which is exactly what a Definition 6 reader does not have.

There are three closed axes now, where you counted two: basis, method and attribution, all REQUIRED, per SPEC:1093-94; attribution became normatively read in this revision. And at the revision you read, SPEC:1704 still said "The two axes this predicate does order", which was stale against SPEC:1063 in the same document. That one is against us: an internal inconsistency in the PR, independent of your comment, and I would not have gone looking for it if you had not made me count the axes. It is fixed below.

Appendix E of the paper already states your finding as a general rule: "A reader who cannot tell which method produced a record must treat the record's independence claim as unproven." §4.4 levels the same complaint at a commercial product and calls it "the check a buyer should ask for". So you have not found a gap in the argument; you have found the argument applied to my own wire format, which is worse for me and better for the document.

The asymmetry worth naming: the spec states the non-identification as a design intention at SPEC:998-1002, and its residuals section at SPEC:663-760 lists four things the 0.7 commitments do not close, none of which is vantage-class opacity. The paper says it three times and the spec never says it as a limitation. That is the fix I owe, and I agree with you that it does not need a new field to make.

You said the placement is my call. I took the third option you offered, a stated limitation in the residuals list, plus a pointer to the refinement path the spec already carries at SPEC:775-78, where consumer policy MAY subdivide attested into stricter refinements such as requiring a hardware-attested observation key. That names the escape without spending an axis on it.

Pushed as a4cb887

  • The sealed kind constraint now carries the same equality the arming bullet has, inside the same MUST carry scope, so the no-row case is settled on both sides rather than one.
  • The axis sentence is corrected to three. It now reads "defines" instead of "orders", because writing that the predicate orders three axes would have been a fresh error: pinned "does not make the row's method stronger, does not raise its evidence tier, and does not enter the result recompute except through the fail-closed arm every required row member with a closed vocabulary shares", and both SPEC:766-68 and SPEC:1058 say "either strength ordering". Three axes with normative readers, still two orderings.
  • Vantage-class opacity is now a fifth residual, placed after the "One limit is common to all four" capstone so the section's existing counts stay true, and opening by saying it belongs to no member.

None of it needs a new member on any closed vocabulary. The residual says why a carried value would not help: a vantage-class member would be a producer assertion about the producer's own stack, exactly as forgeable as the rest of the payload, and it would state at the predicate level a thing the consumer's key policy already decides.

Edited 2026-08-25. The quotation of the pinned sentence above was cut before its final clause. It now runs to the end. The omitted words say that pinned does enter the result recompute through the fail-closed arm, so cutting there made the value look more inert than the text does, and it cut in favour of the point I was making. Correcting it here rather than in a reply, so nobody reads the shortened version first.

Edited 2026-08-31: prose and formatting only. No SPEC line reference, quotation, commit or claim changed; some repeated field names lost their backticks after first mention.

@Rul1an

Rul1an commented Aug 25, 2026

Copy link
Copy Markdown

Re-read a4cb887 against Rul1an/aee-checker main at 25b9dfa.

The seal side is closed. With the equality now inside the sealed kind constraint's MUST carry scope, the no-row sweep has a specified comparand on both sides; the pinned-posture check our implementation already applies there no longer depends on an unwritten row-scoped reading. No checker behaviour changes as a result.

Your zero-hit result for "carried arming" is also right: the phrase was ours, not a quotation from this specification. We used it to name the wider quantifier reading while rejecting it, and recorded that the corpus could not distinguish that reading from the narrower row-resolved one. The current checker no longer emits that phrase after the reporting fix; it remains only in historical, digest-pinned records and explanatory tests/comments.

Your narrowing of the arming half stands, and we had not claimed otherwise. Thanks for checking this against the tree rather than recollection.

A refusal names a comparison, and nothing until now required that the set it
names be the set the implementation actually ranged over. Three shapes break
that, and they break it differently, so the clause states each with its own
remedy rather than as one caveat.

A comparison whose operand set was empty did not run. The document already
argues this twenty lines above, where the attribution requirement exists
precisely because a requirement universally quantified over an empty set is
vacuously true, so the clause cites that argument rather than repeating it: a
verifier must not name such a comparison, and should report the empty set,
which is what it found. A set described wider than the one the check ranged
over is the shape that costs a producer real work, because it tells them to
repair records the check never read; where the evaluated set is a function of
the statement, as the set of arming records a row resolves is, the clause asks
for that set and its count. And a comparison against a commitment genuinely
ran, but has no exhibitable membership at all: a digest mismatch says the
recompute differs and says nothing about which element differs, so a refusal
describing it as a comparison over a set reports a capability the construction
denies it.

The obligation is diagnostic and never a validity rule, and it sits in the
paragraph that already establishes that hedge so it inherits it. No conformance
vector can enforce it. Deleting an emptiness guard leaves a suite green because
the enclosing universal is vacuously true on the empty set, which is the same
structural zero the clause describes; and this document defines no condition
vocabulary, so what a refusal is drawn under is not fixed here either. What is
fixed is only what a refusal may claim to have compared.

Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
elang2 added a commit to elang2/attestation that referenced this pull request Aug 27, 2026
Revise the AI Agent Action predicate based on detailed review feedback:

- Add checkpoint and chain_break as action.type values with sub-schemas,
  addressing the tail truncation detection gap
- Add parties array with witness/asserter roles for field provenance
- Split canonicalization: signing tuple-array (M/L-tagged) for chain
  integrity, RFC 8785 JCS for content digests (float-safe for MCP payloads)
- Document genesis convention (previousHash: "genesis") and chain_break
  requirement for crash recovery
- Add I-JSON safe integer bound on all integer fields (RFC 7493)
- Add 128-level depth bound on extensions with counting rule per in-toto#570
- Replace placeholder digests with fully recomputable worked example
  (every digest has a shown preimage, verifiable against conformance vectors)
- Add Security Considerations section
- Move listing to community contributions per ITE-63 process

Signed-off-by: Elan Ansrinivasan <5340827+elang2@users.noreply.github.com>
elang2 added a commit to elang2/attestation that referenced this pull request Aug 27, 2026
Revise the AI Agent Action predicate based on detailed review feedback:

- Add checkpoint and chain_break as action.type values with sub-schemas,
  addressing the tail truncation detection gap
- Add parties array with witness/asserter roles for field provenance
- Split canonicalization: signing tuple-array (M/L-tagged) for chain
  integrity, RFC 8785 JCS for content digests (float-safe for MCP payloads)
- Document genesis convention (previousHash: "genesis") and chain_break
  requirement for crash recovery
- Add I-JSON safe integer bound on all integer fields (RFC 7493)
- Add 128-level depth bound on extensions with counting rule per in-toto#570
- Replace placeholder digests with fully recomputable worked example
  (every digest has a shown preimage, verifiable against conformance vectors)
- Add Security Considerations section
- Move listing to community contributions per ITE-63 process

Signed-off-by: Elan Ansrinivasan <5340827+elang2@users.noreply.github.com>
astrogilda added a commit to astrogilda/aee-conformance that referenced this pull request Aug 27, 2026
VENDOR-PIN.json named in-toto/attestation and left ref empty. The commit
it pins is not in that repository: a plain clone of it resolves neither
0dbe10bc nor 639ec56c, while the same clone resolves its own HEAD, and a
clone of astrogilda/attestation resolves 0dbe10bc on branch
predicate/adversarial-execution-evidence, where it is the direct parent of
a4cb887 and its spec file is 147709 bytes hashing 759d2383, byte-exactly
the pin's own specDigest. So the digest was right and the address was
wrong, and a reproducer following the pin arrived at a repository the
bytes are not in.

The cause is one field answering two questions. A pull request is REVIEWED
in the upstream repository and opened FROM a branch in a fork, and
upstreamRepo was being read as both. It stays as the review venue, because
gen_manifest.py builds the citation in-toto/attestation#570 out of it and
that citation is correct. Where to fetch becomes commitRepo, ref and
refKind, and all three are derived from the checkout's remotes rather than
typed, for the same reason the commit already was.

vendor-spec.py now refuses to write a pin whose commit is not reachable
from the ref it names. That refusal fired on its first run against a real
tree: the local branch is one unpushed commit ahead of the fork, so
vendoring from it would have pinned a commit no reproducer could fetch.
--at separates the commit vendored from the ref that contains it, which is
the situation here, since the corpus certifies against an ancestor of the
branch tip.

refKind is the part that says the pin is currently-true rather than
permanent. A branch head moves and this one already has. The tag that
fixes that is a remote write and is left for the operator, with the
commands in TODO.md. Dry-running those commands first is how the
annotated-tag peel bug surfaced: rev-parse on an annotated tag returns the
tag object, git show dereferences it, so the digest check passed while the
pin recorded an id that is not a commit.

The two documents that quote the provenance are regenerated from their
generators. Both said "upstream commit <sha>" beside a tracksUpstream of
in-toto/attestation#570, which reads as an instruction to fetch from
there; both now name the fetchable location and the review venue
separately.

Staging the pin in the forcing gate's test rig is the other half. The gate
reads a file the rig did not copy, so every rendering case died on a
missing file and reported that as its own verdict: the absent-prior case
failed saying the prior record had not stopped the run, when the run had
stopped one file earlier. The gate also refuses now instead of raising,
because a traceback exits 1, which is this gate's code for a stale
document.
@zlhk100

zlhk100 commented Aug 27, 2026

Copy link
Copy Markdown

Thanks — the limitation as written reads correctly, and the vantage-class decline is a reasonable place to land it.

One data point you may already know, in case it's useful. SLSA meets both of your objections and lands the other way: builder.id is a producer-supplied string naming the platform rather than the key, and the spec keeps it REQUIRED even if it is implicit from the signer. It also asks that modes with differing security attributes each carry a different id — GitHub-hosted versus self-hosted runners is the worked example — to minimize the risk that a less secure mode compromises a more secure one.

Not arguing it's the better call here; builder.id is self-declared and carries the same forgeability you name. Only that neither forgeability nor overlap with key policy settled it in that predicate, which I found interesting given the family.

Happy to leave it there. Good luck with the vetting.

elang2 added a commit to elang2/attestation that referenced this pull request Aug 28, 2026
…JSON

Adopts the canonicalization and chain-shape replacement prose contributed
in review by Sankalp Gilda (@astrogilda), adapted from the text of
in-toto#570.

- bad-101..bad-104: the chain hash preimage is now the record canonical
  form: RFC 8785 (JCS) over the complete audit record including its
  attestation member. Producers MUST write the JSONL line as exactly these
  bytes; verifiers MUST recompute and reject on byte mismatch, fail-closed.
  JSON.stringify is banned from deriving the record canonical form.
- bad-105: strict I-JSON statement-wide. Duplicate members at any depth
  reject fail-closed; well-formed-string rules on raw bytes and \u escapes;
  the 128-level depth bound now covers the whole record.
- bad-107: checkpoint linkage wired. Every record type carries
  predicate.chain.previousHash; checkpoint.previousHash restates the head
  and MUST equal it; deleting a checkpoint now breaks documented linkage.
- Unnumbered findings: previousHash constrained to lowercase 64-hex or the
  literal genesis; second-genesis detection upgraded SHOULD -> MUST reject;
  content digest preimages pinned (params member for requests, result
  member for success responses, error member for error responses); floats
  permitted in content payloads only; the signing-form field list
  enumerated in the spec text for all three record types.
- Widened the tool_call signing tuple to cover type, errorClass, the
  content digests, attestorVersion, and configHash (adopting the reviewer's
  proposed additions), and added the Underlying record shape section
  pinning record-vs-Statement membership and the three protection layers
  (signing tuple, chain hash, DSSE envelope).
- Hardening from adversarial self-review: RFC 2119 requirements notation;
  attestation byte encoding pinned (lowercase 64-hex HMAC-SHA256, 128-hex
  Ed25519); malformed JSON-RPC responses recorded with success false and
  no response digest rather than suppressed; predicate.chain REQUIRED on
  tool_call and checkpoint records (null-priorHead chain_break is the
  single legitimate omission); chain_break prior* members always present,
  explicit null when unknown; id pinned as opaque, at most 128 bytes,
  unique per chain; JCS string rules applied universally in the signing
  form; protocol and upstream.transport declared Statement-only,
  DSSE-covered annotations with fixed v0.1 vocabularies.
- Conformance corpus subsection pinning astrogilda/aee-conformance, with
  the conformance MUST binding at the pin regenerated against this text.
- Security Considerations: planted-break residual risk named with three
  implementable mitigations; signature-scheme trust model documented
  (Ed25519 for attestor accountability, HMAC-SHA256 only inside a single
  trust boundary).
- Extensions retention pinned: post-signing stripping is forbidden (it
  corrupts the chain preimage); a never-inlined workflow emits
  extensionsDigest with extensions absent at emission time. Scope
  semantics clarified: scopes describe witnessing, not signing;
  structural members are emitter-witnessed by construction; unscoped
  fields remain unknown provenance, fail-closed.
- Markdownlint conformance against the repository config: signing-form
  field lists moved to fenced blocks, fence languages and list-marker
  spacing normalized. Checkpoint and chain_break Statement schemas now
  show the metadata block their worked examples carry, and
  decisionContextDigest is pinned to lowercase 64-hex with a
  deployment-defined preimage at v0.1.
- Worked example recomputed under the record canonical form and widened
  tuple, with subject digests fixed to the genesis chain hash on every
  statement in the chain.

Co-authored-by: Sankalp Gilda <23521054+astrogilda@users.noreply.github.com>
Signed-off-by: Elankumaran Srinivasan <5340827+elang2@users.noreply.github.com>
@astrogilda

Copy link
Copy Markdown
Author

Rul1an, four things have landed since your last read: a tag, a repository name in the vendor pin, a digest pin, and the refusal-set clause. I announced none of them. A silent push and no push look identical from outside. The checks are written out below so you can run them yourself.

Start with the suite commit that sat on no ref. You suggested a ref, if I wanted the pinned revision reachable the way a reproducer reaches things. There is one. It went up the same evening: an annotated tag, cited/5019931.

Its message records why. History was rewritten after the revision was published. The commit is an ancestor of nothing. A plain clone still carries it, because clone fetches tags.

I checked that the way a reproducer would, and not the way my CI does. A plain git clone, then git cat-file -e 5019931^{commit}, which exits 0. A bogus SHA in the same clone exits 128. The check is known to discriminate.

On the vendored specification's dangling upstream commit. Your finding was that 0dbe10b resolves in neither in-toto/attestation nor the fork. The real defect was one level up. The vendor pin named a commit. It never named the repository holding it, so a reader had to guess, and the natural guesses fail. be67a74 adds commitRepo: astrogilda/attestation to spec/VENDOR-PIN.json.

A plain clone of that repository reaches the commit. git branch -r --contains puts it on the branch this pull request is opened from. The digest checks out at 147709 bytes, sha256 759d2383. That is the figure you published.

One retrieval caveat worth passing on, because it gave me a wrong answer first. The GitHub API resolves that same SHA under in-toto/attestation as well, since forks share an object store. It answers 200, not 404. So an API lookup cannot settle the question you actually asked. Only a clone can, and the two disagree in the direction that reassures you.

For the agent-action corpus the fix was to stop pinning a commit at all, since its upstream commit was 639ec56. That one is genuinely orphaned. The fork branch was rewritten. A plain clone of elang2/attestation exits 128 on it. That clone's own HEAD resolves.

7aed3cc moves the manifest onto specAuthority: specDigest over vendored bytes in the directory, which the checker recomputes on every run. The orphaned commit is now provenance only, since integrity no longer depends on it resolving. That is stronger than pinning a ref. It stops depending on anybody's history staying put.

Finally, the refusal-set clause, and this is the part I most want you to check. Commit b1513f6 states the general clause in the specification text. Where a refusal names a comparison, the set it names must be the set the implementation evaluated. All three of your shapes are there. Each has its own remedy, and none is folded into a single caveat.

An empty operand set means the comparison did not run. A verifier must not name it. It should report the empty set, which is what it found.

A set wider than the one the check ranged over tells a producer to repair records the check never read. A verifier must not name a wider set. Where the evaluated set is a function of the statement, it should name that set and its count. The arming records a row resolves are exactly such a set.

A comparison against a commitment genuinely ran, and may be named. It must not be described as a comparison over a set whose membership it cannot exhibit. A digest mismatch says the recompute differs, and says nothing about which element differs.

The general form is yours. You wrote that emptiness is the special case, and that the set a refusal describes must be the set the implementation evaluated. I took your sentence over my narrower one, and the commit message says so.

The limit is worth stating too. No vector can enforce any of it. Your own bite test found the reason. Deleting an emptiness guard leaves a suite green, because the enclosing universal is vacuously true on the empty set. The obligation is diagnostic and never a validity rule, and it sits in the paragraph that already carries that hedge.

One thing I have not done, so you do not go looking for it: the vendored copy in the suite is still pinned at 0dbe10b. It carries neither a4cb887's seal-posture equality nor this clause. The pin is deliberate and its digest matches. It moves when the suite re-vendors, which is a separate change from this one. But if you re-read the vendored text expecting the new paragraph, you will not find it there; it is at the branch head.

Sankalp.

@astrogilda

astrogilda commented Aug 28, 2026

Copy link
Copy Markdown
Author

@zlhk100, thank you, and the SLSA point is better than a data point. It counters the reasoning I gave, so I want to answer it directly.

My residual declines a vantage-class member on two grounds. The value would be a producer assertion about the producer's own stack, as forgeable as the rest of the payload. And the discriminating fact already lives in consumer key policy. You have shown me a predicate in the same family where both hold of builder.id, and neither settled it. That is a real problem for the argument as written, because I offered those two grounds as though they were enough on their own.

The practical difference is in what the field is for. That field is a partition key rather than a graded claim. A consumer's policy matches on it, and SLSA keeps it mandatory even when it is implicit from the signer. That is what makes it usable: the field is there to be keyed on, not to be believed. The guidance you quote about modes with differing security attributes is the same thought, since a partition is useless if two modes share a label.

My axes are graded. They feed the evidence tier and the result recompute. A self-declared value in that position would be forgeable in a way that changes a verdict rather than a lookup.

So the correction is narrower than a reversal. I still decline a graded vantage axis. But what I actually wrote argued against any carried value at all, and that is wider than my grounds reach. An ungraded partition identifier is a different proposal, it is the one SLSA validates, and I have not evaluated it. So I will narrow the residual's wording to the graded axis it actually rules out. That is a text change in the residuals section, and it adds no member to any vocabulary.

None of that changes the limitation itself, which stands either way. A third-party reader still cannot tell a kernel-vantage monitor from a monitor outside the workload. An identifier the producer chooses would not by itself let them. It would no more do so than builder.id lets a SLSA consumer judge a runner they know nothing about. What it would give is something for a policy to key on, which is a smaller and more honest thing to claim.

Thank you for reading the substrate half with a custody eye. The comparison to the SLSA family is useful context, and I am glad you raised it.

@benrrr56-wq

Copy link
Copy Markdown

The Adversarial Execution Evidence predicate is a strong addition to the attestation family: running an untrusted artifact against a known corpus produces evidence that attests observed behavior, not trust in the artifact. The recomputable-evidence question it raises:

  1. Does the predicate pin the exact artifact digest and version and the adversarial corpus revision? Two runs over slightly different corpora are not comparable if the input set is not reproducibly captured.
  2. The containment profile (what the execution was permitted or denied to do) is the other load-bearing half: without it, the evidence can overclaim what the artifact could have reached vs what it was constrained from. Is the containment policy snapshot bound into the same predicate so an auditor cannot separate observed behavior from allowed reach?

Attesting the boundary between allowed and observed is precisely the evidence layer I build: what an agent is authorized to do vs what it actually did, recomputably. I work on that at https://agentkey.us and open to comparing predicate shapes.

@Rul1an

Rul1an commented Sep 1, 2026

Copy link
Copy Markdown

Sankalp, I reran these as clone checks rather than API lookups.

  • In a plain clone of astrogilda/aee-conformance, git cat-file -e '5019931^{commit}' exits 0; a bogus 40-hex object exits 128.
  • In a plain clone of astrogilda/attestation, 0dbe10bcc959b63dc42370a5db09812c9476f59a resolves and git branch -r --contains places it on origin/predicate/adversarial-execution-evidence.
  • The specification bytes at that commit are 147709 bytes and hash to 759d2383e5da36fa509dc335e6159a20b87641b25ebbadcf1676c55d75ffd8b0, matching spec/VENDOR-PIN.json.
  • In a plain clone of elang2/attestation, its own HEAD resolves and 639ec56 exits 128.

The refusal-set clause also found a defect in our checker rather than only confirming the earlier fix. Two aeeObservedSet refusals said the value was recomputed over “the carried records”, while the implementation evaluates only carried interception and examination records. We corrected both sites, added behavior-level guards for each refusal, and then an independent reviewer found the first guard was asymmetric: dropping Examination from the production filter still left it green. The fixture now carries both evaluated kinds and also makes widening the filter visible. All four mutations bite independently: two wording regressions, narrowing to Interception-only, and widening to include Sealed. The fix and SHA-bound review are in Rul1an/aee-checker#18; the post-merge provenance binding is in #19.

On the remaining wording question: I found no occurrence of “short-circuit” or “conjunct” in the clause. Our earlier && implementation could emit one byte-identical refusal for four distinct failures because later conjuncts were never evaluated. I read the general MUST as covering that: a refusal cannot name comparisons the implementation did not reach. But that mechanism is not literally one of the three shapes the following sentence enumerates. Are those three intended as non-exhaustive examples under the general rule, or as the complete set of shapes the paragraph means to constrain?

I did not run the current corpus against this checker or derive a number. The checker targets predicate v0.6; the corpus is v0.7 with no alias or dual-accept window, so the type refusal would make such a figure meaningless.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants