ERC-8436: Agent Collective Decision Framework

Hi all,

This is a draft ERC for an Agent Collective Decision Framework (ACDF): two co-deployable registries through which qualified participants — agents, humans or contracts — form collective decisions with a defined effect, within explicit authorization, under rules that are frozen as the very parameters the registry executes.

Repository (design memo, reference implementation, tests, vectors): garyyang-finchip/acd-framework
ERC text: ERC-8436 (Draft) — merged into ethereum/ERCs via #2046 on 2026-10-14. Latest: revision 2 — obligation scope, appeal mode, finality basis · revision 2 on Sepolia — two live cases, one appeal · body-role validation.

The slot this fills

Several agent-economy standards stop at a single trusted address at exactly the point where a decision is needed: ERC-8183’s evaluator, the acceptanceAuthority of the token-bound task tender draft (ERC-8414), an arbitrator in the ERC-792 lineage. ERC-8033 standardizes one specific council flow for information queries. Governors assume one electorate and one weighting; a Safe gives a roster a K-of-N threshold but has no notion of a question, a result record or a procedure that can end without a decision.

ACDF is the thing those slots are waiting for: one interoperable record of which rule decided which question about which subject, who had committed in advance to accept the result, whether the procedure is open, provisional or final, and whether it ended in a decision at all.

What it specifies

  • Decision Policy — an immutable, content-addressed PolicySpec (policyId = keccak256(abi.encode(spec))): bodies, thresholds, composition graph, windows, appeal rules. The rule as published and the rule as executed are the same object; no proxy / admin-list / code-hash argument needed. Versions are linked through a policy family whose update authority alone publishes the next version; publishing never touches issues bound to earlier versions.
  • Issue — one concrete decision: subject + question + policy version + at most one consumer (relying contract). Three acceptance modes, all of them acts of the consumer: CONSUMER_FILED (the relying contract files), STANDING_ACCEPTANCE (it pre-declares what it accepts; listed filers file within that), POST_ACK (anyone files, the named consumer must acknowledge before admission and voting — otherwise the issue proceeds only as Advisory, and an Advisory issue can never be upgraded). One unfinished Binding issue per (consumer, subject, question): no verdict shopping.
  • Bodies — fixed-roster K-of-N (on-chain ballots, or EIP-712 signed ballots relayed by anyone, ERC-1271 for contract voters, no nonce — identity is the only key) and authorized submitters. Composition with ALL / ANY / K-of-M / VETO under four-valued semantics (Pending / Yes / No / NoDecision): two chambers compose by logic, never by pooling ballots, and a live veto window can never be extinguished by an early settlement.
  • Finality — procedure state (Filed → Deciding → Provisional → Final), outcome type (None / Decided / NoDecision) and enactment status are three separate dimensions. Final is terminal. Appeals rebuild all bodies under the same policy; an appeal round that fails to reach quorum keeps the decision being appealed (sourceRound ≠ roundCount). Appeal windows run from the instant the ballots determined the result, not from the settlement transaction.
  • No execution in the kernel — the registry never moves assets. Relying contracts read Final × Decided and enforce their own committed effects; a NoDecision triggers only the disposition the consumer committed to in advance (for a task tender: nothing — the tender’s own “silence pays the fulfiller” default governs).

Minimal conformance is a fixed-roster K-of-N vote. Composition, signed ballots, authorized submitters and appeals are normative-optional profiles over the same objects. Eligibility profiles (e.g. ERC-8004 identities with KYA-style trust assertions, ERC-8419 / ERC-8434 in my other drafts), sortition, weighting, non-binary outcomes, executors, fees, privacy and cross-chain carriage are explicitly reserved, not specified.

What exists today

  • Reference implementation (Solidity 0.8.24, non-upgradeable): ACDFPolicyRegistry (9.6 KB), ACDFRegistry (23.5 KB, under EIP-170 with via-IR), ACDFTaskTenderAdapter.
  • 129 Foundry tests in 8 suites covering: minimal tally edge cases and policy validation; acceptance modes, freezing, withdrawal and the obligation rule; every composition branch incl. “result independent of settlement order”; rounds, appeals, adoption rule, hard deadlines, replay of earlier-round signatures; signed-ballot verification incl. ERC-1271 and malleable signatures; and an end-to-end adapter run against the vendored real task-tender kernel (accept pays the worker, reject releases the reservation, NoDecision leaves claimUnjudged in force, late execution refused by the kernel, retry after an epoch-pacing refusal).
  • Cross-language vectors: policyId and EIP-712 digests produced with ethers v6 and re-derived in Solidity; 83-row K-of-N and 192-row composition truth tables generated by an independent JS implementation of the rules.
  • Sepolia: revision 2 deployed with two live end-to-end cases against the live ERC-8414 task tender — Case A (PRESERVE, accepted and paid out) and Case A2 (REQUIRE_FRESH, appealed, ended in NoDecision and left the tender’s own default in force). Addresses, transactions and what remains local-only: revision 2 on Sepolia; the first deployment (v0.2) is recorded in the earlier follow-up.

Questions I would value feedback on

  1. Obligation granularity. The kernel keys “one live binding issue” on (consumer, subject, question). Is that the right level, or should the kernel also fix the business key inside subject.dataHash rather than leaving it to the adapter?
  2. Adoption rule on appeal. When an appeal round ends in NoDecision, the earlier substantive decision is adopted. The alternative (“re-hearing cancels the earlier result”) is left to a future explicit configuration. Objections?
  3. VETO semantics. An approve ballot in the veto body means veto, an explicit block means clearance, silence means pass-through or no-clearance per policy, and the node stays Pending while the window is open regardless of the target. Is anything missing for the screening-council use case?
  4. Signed ballots without a nonce. De-duplication is per (issue, round, body, voter); a second signature is worthless by construction. Is there a case where a nonce is still needed?
  5. Eligibility. The kernel only requires rosters to be fixed and frozen at admission. Would reviewers rather see an eligibility profile (identity + trust assertions, operator caps, conflict exclusion) in this ERC, or kept separate as planned?

Thanks — Gary

Signed ballots explicitly bind the round. Why doesn’t castBallot also take an expectedRound?

Suppose a voter broadcasts a transaction during round 1. Other votes settle that round and an appeal opens round 2 before the pending transaction is included. Could that transaction then count in round 2 even though the voter intended to vote only in round 1?

Would requiring expectedRound and reverting on a mismatch make the authorization boundary more consistent between signed and on-chain ballots?

Sepolia deployment and the first live case

The reference implementation is now on Sepolia, and one real submission on the live ERC-8414 task tender (the token-bound task tender draft’s reference TaskToken, 0xA62059A498E40C4Ae4aF926E2B00C1Ff122bDdb7) has gone through the whole procedure.

Contracts (solc 0.8.24, via-IR, optimizer runs 1; runtime code byte-identical to the repository build):

  • ACDFPolicyRegistry 0x8b454635ad6CBd7649418df73776ED9FbD39f668
  • ACDFRegistry 0x9ef9b6c68b2de3aCdaB42fbeca326816D1316a69
  • ACDFTaskTenderAdapter 0x76986Fd0Cc636Bf4C54A2F9145463F9893b9635F
  • policy v1 0x0f36cb699dafbdab0b08057665b6a716316e730409f5cbdfe53a5241f99ff279 — family keccak256("acdf.sepolia.case-a"), one body: fixed roster of five, 3-of-5, EIP-712 signed ballots, one appeal, 10-minute appeal window, 3-hour hard cap

Case A, blocks 11844486–11844663 (2026-10-04):

  1. Task #8 minted on the live TaskToken with the adapter as acceptanceAuthority; 0.001 ETH escrowed, one completion, 2-day judgment window.
  2. The worker submitted; adapter.open(8, 1) filed a Binding CONSUMER_FILED issue (0xa5898a1a…bc4df) bound to the exact submission (resultHash, cited taskVersion) and to submittedAt + judgmentWindow − margin as the consumer deadline; the registry admitted it with a hard cap of 3 hours inside that deadline.
  3. Four signed ballots relayed in one transaction: one No, then three Yes. The body decided Yes at the arrival of the third approval; the dissent is on record.
  4. settleRound → Provisional. The appeal window ran from the decision instant (block 11844525), not from the settlement transaction (block 11844526).
  5. After the window: finalize → Final × Decided(Yes), sourceRound = roundCount = 1. adapter.execute called the real acceptFulfillment; the kernel paid the 0.001 ETH reward to the worker, and the registry recorded the enactment as Enacted. The registry itself never touched the escrow.

Every transaction, event and assertion: deployments/sepolia.json and deployments/sepolia-case-a.json in the repository; the four forge script runs are in script/SepoliaCaseA.s.sol with docs/sepolia-runbook.md, so anyone can repeat the case with their own accounts. Total: 13 transactions, 11.3M gas.

What this does and does not show: the accept path of the ERC-8414 adapter and the signed-ballot profile are now exercised on a public network against the real kernel. The reject path, NoDecision with the tender’s own claimUnjudged clock taking over, composition, veto windows and appeals remain covered by the 119 local tests only. The policy family’s update authority currently sits with the deployer account; a later policy version can move it.

Feedback on the five questions in the first post is still what I am most after — in particular the obligation key granularity and the adoption rule on appeal.

The Sepolia case is the right milestone here. It establishes something stronger than another fixture. The real ERC-8414 accept path, signed-ballot decision, procedural finality and consumer-side enactment have now composed on a public network, while the registry remained only the decision record. I also appreciate the explicit boundary on what it does not establish yet — reject, NoDecision, appeals, veto/composition, etc. remain test evidence rather than live-path evidence.

On the two design questions you called out, I think the same separation helps. For the obligation key, I would separate issue identity from obligation identity. Binding an issue to the exact resultHash / taskVersion is correct: that freezes exactly what this proceeding evaluates. But the anti-verdict-shopping key is answering a different question — which consumer execution obligation must have at most one unfinished Binding proceeding. I don’t think the generic kernel can always infer that from Subject.dataHash.

I’d therefore prefer an explicit consumer-committed obligationId (or equivalent canonical scope), with exclusivity keyed by something like (consumer, obligationId, question). The full Subject remains frozen on the issue. A consumer can choose the exact submission as its obligation scope when that is semantically right, but the ERC does not silently make evidence identity equal authority identity.

On appeal adoption, the current “latest substantive decision survives a later NoDecision” rule is coherent for a challenge-style appeal: failure to overturn leaves the predecessor decision standing. I would make that rule policy-explicit rather than universal, though. A de-novo appeal has different semantics: opening the successor proceeding vacates predecessor authority and requires a fresh substantive decision. So I’d freeze an appeal mode along the lines of PRESERVE_UNLESS_OVERTURNED vs. REQUIRE_FRESH_DECISION.

Dmitry’s expectedRound point fits the same model. Signed ballots already bind their intended round, which is why the live Sepolia case is safe on that path. Direct round-local actions should bind the same authority epoch rather than allowing a delayed transaction to acquire meaning in a successor round. The common invariant for me is:

evidence may survive a transition; authority crosses it only when the committed procedure says it does.

With obligation scope explicit, appeal inheritance explicit, and direct round-local intent bound to its round, I don’t see another unresolved authority-semantic gap in the kernel.

@chugarchugarr @garyyang-finchip on question 2 and the appeal mode in #4: there is a deployed system that runs both modes in sequence, which may help pick the default.

The Polymarket adapter on UMA treats a first dispute as REQUIRE_FRESH_DECISION: the disputed proposal is discarded and the question is asked again. Only a second dispute escalates to a token-holder vote. Across 3,005 disputes on 2,666 markets (1 Jan to 2 Oct 2026), 853 of 2,543 settled markets (33.5%) settled differently from the disputed proposal. So a challenged decision failed to stand about one time in three.

That makes PRESERVE_UNLESS_OVERTURNED risky as a universal rule, because it pairs badly with hard caps. Median time from first dispute to resolution was 3.3 h, but 88.5 h once a second dispute escalated it. An appeal round sized like the first round will often end in NoDecision by timeout, and under preserve semantics the clock, not the appeal body, then confirms the challenged result. I would keep the mode policy-explicit as #4 suggests, and add that consumers should be able to read “Final via adoption after NoDecision” distinctly from “Final via substantive decision” (sourceRound != roundCount already carries most of this).

@SergeevDmitry Thank you — the scenario is real and the fix is the one you describe.

The gap: a signed ballot is bound to its round by the EIP-712 digest, so a round-1 signature can never be accepted in round 2. An on-chain castBallot had no such binding. A voter who broadcast during round 1 while the remaining ballots settled the round and an appeal opened round 2 would have had the pending transaction counted in round 2 — and because ballots cannot be changed, there would have been no way to undo it. The two acceptance paths were supposed to share one rule and did not.

Change (now in the reference repository and pushed to PR #2046):

  • castBallot(bytes32 issueId, uint32 round, uint32 body, bool approve) — the registry requires round == roundCount and reverts with ACDF: round mismatch otherwise.
  • submitBodyResult(bytes32 issueId, uint32 round, uint32 body, NodeStatus status) — same rule for authorized-submitter reports, which had the same exposure.
  • Spec text: Sections 7.2 and 7.4 state the requirement; Section 11 carries the new signatures; Section 16 lists the new reason string; a Rationale paragraph records why; the ERC-165 identifier of IACDFRegistry changes from 0x6cb878d2 to 0x843ad5a8 (Section 15 and interface-ids.json).
  • Tests: two new cases — a round-1 ballot arriving after an appeal opened round 2 is refused while a ballot naming round 2 is accepted, and a future round is refused as well; the same for a submitter’s report. 121 tests pass. The registry’s runtime grows by 112 bytes to 23,626 (still under EIP-170).

What does not change: submitSignedBallots already carries the round inside each signature, so its external signature stays as it was; the adapter and the policy registry are untouched.

On Sepolia: the instance deployed yesterday predates this change. Case A used signed ballots only, whose round binding is unaffected, so the recorded evidence stands as evidence for that profile; the on-chain-tally path of that instance has the old signature. I will redeploy the registry with the next batch of review-driven changes rather than once per fix, and will post the new addresses here.

Thanks again — this is exactly the kind of boundary check the draft needed.

1 Like

@chugarchugarr @predge-ai Thank you both — this answers the two questions I most wanted answered, and both changes are now in the reference repository and on PR #2046 (revision 2, together with the ERC-8436 renumbering).

1. Obligation identity is no longer the subject. IssueInput carries a consumer-committed obligationId; exclusivity of unfinished Binding issues is keyed on (consumer, obligationId, question), and the full Subject stays frozen on the issue as the evidence identity. Per acceptance mode: CONSUMER_FILED requires a non-zero obligationId from the consumer; a STANDING_ACCEPTANCE either fixes one scope for every issue under it or, with obligationId = 0, elects the exact subject as the scope (keccak256(abi.encode(subject))) — both committed by the consumer in advance, never chosen by the filer; POST_ACK takes the scope at acknowledgment, the filer’s value being a proposal only. The ERC-8414 adapter scopes its obligation per submission, keccak256(abi.encode(task, tokenId, submissionId)), which is where the exact-submission binding that the subject already carries happens to coincide with the authority’s duty — by the adapter’s choice, as you put it, not by the kernel equating the two. Security Considerations now say what a bad scope costs: an identifier that varies with incidental data defeats the rule, one that is too coarse blocks independent proceedings.

2. Appeal mode is policy-explicit, with no kernel default. PolicySpec.appealMode ∈ {PRESERVE_UNLESS_OVERTURNED, REQUIRE_FRESH_DECISION} is inside the hashed struct, so every participant sees it before the first ballot. Under PRESERVE the adoption rule is the previous one (the latest substantive decision survives a later round that ended without one). Under REQUIRE_FRESH opening an appeal vacates the earlier decision; only the last round’s own Yes/No can be adopted, otherwise the issue ends in NoDecision and the consumer’s committed disposition applies. @predge-ai, your point about preserve semantics pairing badly with hard caps went into Security Considerations almost verbatim: an appeal round that times out under PRESERVE confirms the challenged result by the clock, not by the appeal body, so such policies should size appeal windows and maxTotalDuration for the real difficulty of an appeal or choose REQUIRE_FRESH. Per-round timing (an appeal round longer than the first) is listed as a reserved extension rather than specified now; in this version every round uses the policy’s body windows.

3. Finality by adoption is readable. Result.adoptedFromEarlierRound is true exactly when outcomeType == Decided and sourceRound < roundCount, and the spec states that a consumer MAY commit to treat finality by adoption differently from finality by substantive decision. Under REQUIRE_FRESH the flag is always false.

Dmitry’s round binding (#2/#6) stays as shipped; your invariant — evidence may survive a transition, authority crosses it only when the committed procedure says so — is a good one-line summary of all three changes, and I have borrowed it for the Rationale.

Consequences: PolicySpec gained a field, so every policyId changes and the policy-id vectors were regenerated; IssueInput, StandingAcceptanceInput, acknowledge, obligationKeyOf and Result changed, so the ERC-165 ids are now IACDFPolicyRegistry 0xb362eb4e and IACDFRegistry 0xd31aae30. Tests 121 → 129 (obligation separation, standing-acceptance scopes, both appeal modes, the adoption flag, mode inside the hash). ACDFRegistry is at 24,212 bytes of runtime, 364 under the EIP-170 limit — the next change that needs room will move revert strings to custom errors.

Sepolia: the instance deployed on 2026-10-04 runs the v0.2 interfaces and stays as recorded; I will redeploy with the current code, this time also exercising an appeal path, and post the new addresses here.

@chugarchugarr, with the three gaps closed, I read your last line as the kernel’s authority semantics being complete for v1; if anything in the way the obligation scope is committed per mode looks off to you, that is the part I would most like checked.

Revision 2 is on Sepolia, and this time the appeal path ran for real. The contracts are the ones in PR #2046 (consumer-committed obligationId, policy-explicit appealMode, round-bound ballots, the editor’s renames); I compared the deployed runtime of all four with a fresh build of the assets, byte for byte outside the immutable slots.

Contract Sepolia
ACDFPolicyRegistry 0x7e870B29C903e71517676d52FCCD20Ef96477A73
ACDFRegistry 0xbA03B451B421b5041d7bE7b5Fd7DbcF3B6Aa1354
ACDFTaskTenderAdapter, policy A (PRESERVE_UNLESS_OVERTURNED) 0x09c3550cCAfc2E73B1f0edA3ECA2f5247bB24Da7
ACDFTaskTenderAdapter, policy A2 (REQUIRE_FRESH_DECISION) 0x078271c288141660753b1BfB67A3cfc2F06c8AC2

The relying contract is the live ERC-8414 reference TaskToken (0xA62059A498E40C4Ae4aF926E2B00C1Ff122bDdb7); each adapter is the acceptanceAuthority of one task and files a CONSUMER_FILED Binding issue scoped to obligationId = keccak256(task, tokenId, submissionId). Five jurors, signed-ballot 3-of-5, appeal window 600 s, standing ANYONE.

Case A — task #13, issue 0xf2e88a4e…1dc8, PRESERVE. One dissent and three approvals in one signed batch, settleRound → Provisional(Yes), the appeal window passed with no appeal, finalize → Final × Decided(Yes), adoptedFromEarlierRound = false; the adapter’s execute called the real acceptFulfillment(13, 1) and the worker was paid. getResult: decidedAt 1791526056 (the block of the third approval), finalAt 1791527052.

Case A2 — task #14, issue 0x2ff1ec2f…4cdc, REQUIRE_FRESH. Three approvals, settleRound → Provisional(Yes); the deployer account appealed; round 2 (900 s) received no ballots and settled NoDecision(QUORUM_NOT_MET) → Final × NoDecision, roundCount 2, sourceRound 2, adoptedFromEarlierRound = false. The round-1 Yes is on chain (three BallotCast, RoundSettled(1)) and vacated. The adapter refuses execute (ACDFTender: no decision; judgment clock governs), the submission is still Pending at the kernel, and nothing in ACDF touches the escrow: after the tender’s two-day judgment window claimUnjudged(14, 1) is the kernel’s own default. Under PRESERVE the very same transactions would have produced Final × Decided(Yes) with adoptedFromEarlierRound = true — which is the whole point of putting the mode inside the hashed policy.

A missed Finalize, and what it showed. A first attempt on 2026-10-05 (tasks #9/#10, same registries and policies, earlier adapter build) opened both cases but Finalize was not run before the tender’s judgment window closed. Closing it a day late was instructive: the registry side finalized exactly as it would have on time; Case A’s late execute was still accepted by the kernel, because acceptFulfillment is bounded by settleBy only and this tender has settleBy = 0; and Case A2’s submission was paid by claimUnjudged. So the boundary behaves as decision #36 intended: the adapter’s conservative deadline (judgment window minus margin) applies when it files, the kernel alone decides whether a late enactment is legal and the adapter records Enacted or Failed, and a NoDecision leaves the relying contract’s own default in force. A missed Finalize is a bookkeeping gap, not a loss in either direction.

One operational note for anyone replaying this: Sepolia repriced between the two attempts (base fee a few wei, block gas limit 200M, contract creation about seven times the local estimate), and transactions sized by Foundry’s local simulation ran out of gas while the simulation passed. forge script … --skip-simulation --gas-estimate-multiplier 150 (node-side estimation) fixed it; the runbook says so.

Everything — every transaction with block, timestamp and gas, the decoded getResult of both issues, the first attempt, the superseded contracts — is in deployments/sepolia-cases.json of the reference repository; docs/sepolia-runbook.md has the four commands and the two for a missed Finalize.

Next: Case B (composition with a veto body, on-chain ballots) stays a local test case for now; I would rather spend the next Sepolia run on whatever this thread finds wrong first.

Hi Gary, thanks for sharing the Sepolia walkthrough. Following your example in #8, I tried one extra transaction ordering locally and thought the test might be useful to share.

Using acd-framework at 8b100cc, I added a small regression to the existing adapter fixture with its vendored ERC-8414 kernel:

  1. The committee reaches Final × Decided(Yes).
  2. After judgmentWindow, another caller lands claimUnjudged first. The submission becomes Accepted and the worker receives the reward.
  3. adapter.execute(issueId) then records Failed, with case.enacted == false, because the submission is already terminal. The final decision remains byte-for-byte unchanged. Repeating execute does not pay again.

The behavior lines up with the separation of decision and enactment in §10. For integrations like ours, I think this makes a useful worked example: Failed describes the adapter’s attempt, while the task has already settled through the default path. A short note alongside the example could help us read the task’s submission and settlement/credit records together, so we show the payment status and its source correctly.

I’ve included the test below in case you’d like to add it to the adapter examples. The focused suite passed 17/17, including this one new test, with Forge 1.7.1 / solc 0.8.24. This was a local test, not a Sepolia transaction or a full-suite run. Thanks again for the clear examples — they made this straightforward to check.

The additional test, inside the existing ACDFAdapter8414Test:

    function test_default_claim_before_execute_pays_once_but_enactment_is_failed() public {
        uint256 id = mintWithAdapter(terms(7 days, 0, 0, 0, 2));
        uint256 sid = submit(id, RES);
        uint256 submittedAt = vm.getBlockTimestamp();
        bytes32 issueId = adapter.open(id, sid);
        approve3(issueId);
        reg.settleRound(issueId);
        assertFinalDecided(issueId, true);
        bytes32 finalResult = keccak256(abi.encode(result(issueId)));
        uint256 before = worker.balance;

        vm.warp(submittedAt + 7 days + 1);
        // An independent caller gets the kernel's default claim included first.
        vm.prank(rando);
        task.claimUnjudged(id, sid);
        assertEq(worker.balance, before + 1 ether);
        assertEq(uint8(task.submissionOf(id, sid).status), uint8(ITaskTender.SubmissionStatus.Accepted));
        assertEq(task.pendingOf(id), 0);

        bytes32 effect = adapter.EFFECT_ACCEPT();
        for (uint256 attempt = 0; attempt < 2; attempt++) {
            // This attempt fails at the kernel because the submission is already terminal.
            // The failure must not undo the successful default or pay the reward twice.
            adapter.execute(issueId);
            T.Enactment memory e = reg.getEnactment(issueId, address(adapter), effect);
            assertEq(uint8(e.status), uint8(T.EnactmentStatus.Failed));
            (, bool enacted,,,,,) = adapter.caseOf(issueId);
            assertFalse(enacted);
            assertEq(keccak256(abi.encode(result(issueId))), finalResult);
            assertEq(worker.balance, before + 1 ether);
            assertEq(uint8(task.submissionOf(id, sid).status), uint8(ITaskTender.SubmissionStatus.Accepted));
            assertEq(task.pendingOf(id), 0);
        }
    }

Is body reuse intentional in the composition profile? Validation prevents duplicate node references, but distintc BODY nodes can reference the same body index:

KOFM(2 of 3)
├─ BODY(0)
├─ BODY(0)
└─ BODY(1)

Both BODY(0) leaves read the same per-round BodyState and count separately. When body 0 reaches Yes, the root becomes Yes even while body 1 is Pending. One body decision fills two threshold slots.

There is a related VETO case:

VETO(vetoBody = 0)
  └─ ALL
     ├─ BODY(0)
     └─ BODY(1)

Here, body 0 reaching Yes exercises the veto. Reaching No clears the veto, but blocks ALL. This tree can never return Yes. If reuse is delibertae, I would suggest explicit normative text explaining that repeated references share one decision and can change its effective weight or give it opposing roles.

Otherwise, two validation rules could prevent these cases:

• A body index may appear as a BODY leaf at most once per policy.
• A vetoBody may not also appear as a BODY leaf anywhere in the subtree it guards.

The second rule should stay scoped to that subtree: one screening body guarding several separate targets seems legitimate. I would expect composition to combine distinct body decisions unless reuse is explicitly allowed.

@Revinand Not intentional — thank you, and well spotted. Validation enforced that every node is referenced exactly once and never looked at which body a BODY leaf names, so your KOFM tree lets one decision fill two threshold slots and your VETO tree can never form Yes (body 0 approving vetoes, body 0 refusing blocks the ALL). Composition in this proposal combines the decisions of distinct bodies by logic — that was the whole argument against pooling ballots — so I have closed it in validation rather than documenting reuse. Section 3 now requires, and ACDFPolicyRegistry now checks:

  • a body index appears as a BODY leaf at most once per policy (ACDF: body reused);
  • a body named as any VETO node’s vetoBody appears as a BODY leaf nowhere in the policy (ACDF: veto body votes). This is one step stricter than the subtree-scoped version you proposed: a veto body that votes outside the subtree it guards, e.g. ALL(VETO(0 → BODY(1)), BODY(0)), is the same dead tree — approve vetoes, refuse blocks the ALL — so the roles are simply separated. Your legitimate case stays allowed and is now stated in Section 8: one screening body may guard several separate targets through several VETO nodes; it decides once per round and each guard reads that decision;
  • every declared body is referenced at least once, as a leaf or as a veto body (ACDF: body unused). An idle body would still open ballots and stretch the round to its window.

Tests cover both of your trees, the outside-the-guard variant, an idle body, and the shared-veto policy (registered, cleared by the screening body, both chambers approve, root Yes). PolicySpec and both interfaces are unchanged, so every policyId and both ERC-165 ids stay as they were; only the policy registry’s validation changed (10,201 bytes now). The policy registry on Sepolia predates this and would still accept such a tree; the two deployed policies have a single body each, so nothing there is affected, and it will be redeployed with the next batch of changes rather than for this alone.

@ted0x Thank you for the ordering test — it is in the adapter suite as test_default_claim_before_execute_pays_once_but_enactment_is_failed, credited to you in the test record, and passes unchanged against the current tree (135 tests now). Section 14 says what you asked for: an enactment record describes the adapter’s attempt, not the fate of the obligation; when the relying contract’s own default (here claimUnjudged after the judgment window) settled first, a later execute is recorded as Failed while the submission is already accepted and paid, repeating it changes nothing and pays nothing, and an integration that displays a payment status reads it, and its source, from the relying contract’s records — which Section 10 already makes authoritative for execution state. Your test and the late execute of our first Sepolia attempt (#8) are the two sides of the same boundary: the kernel decides what a late or redundant enactment is worth, the registry only records what the adapter did.

Both changes are on PR #2046 together.

1 Like

Thanks Gary, really appreciate you including the test and the clarification! Reading payment status from the task’s own records makes this boundary much clearer for integrations. Glad the example was useful.

ERC-8436 is now in ethereum/ERCs as a Draft — PR #2046 was merged on 2026-10-14 and the proposal is live at eips.ethereum.org/EIPS/eip-8436.

The merged text is the current revision: round-bound ballots (#2/#6), consumer-committed obligation scope and policy-explicit appeal mode (#4/#5/#7), the body-role validation rules (#10/#11), and the note on reading enactment records (#9/#11). Thanks to everyone who reviewed it here — every one of those changes came from this thread.

This thread remains the discussion venue. Further changes will go through Update ERC-8436 pull requests; the Sepolia registries will be redeployed with the next batch and posted here.

Revision 3 is on Sepolia. It closes the gaps between what the local suites cover and what had run on a public network: the registries now carry the body-role validation from #10, one case composes three bodies with a veto and on-chain ballots, and one case takes the reject path. Same live ERC-8414 TaskToken (0xA62059A498E40C4Ae4aF926E2B00C1Ff122bDdb7), same account set, same scripts style (script/SepoliaCaseBC.s.sol, four runs).

Contract Sepolia
ACDFPolicyRegistry (with the body-role rules) 0x7eAfCC452EC0A60a358a26B6e73f28413C4abeF0
ACDFRegistry 0x3bE57E74f2E7c280073b6B16A1Db3F562758dB01
ACDFTaskTenderAdapter, policy B 0x19B8E0290456fCd771169D6AEdc9e1D771Da426f
ACDFTaskTenderAdapter, policy C 0x0Ef78bddBcf96919B27df84c091a34DfDF4D820b

All four match a fresh build of the assets byte for byte outside the immutable slots.

The body-role rules, observable on chain. @Revinand an eth_call offering your second tree (the veto body also a BODY leaf under the VETO it guards) to the new policy registry reverts with ACDF: veto body votes; the same call against the revision-2 registry (0x7e870B29…7A73) returns a policy id, which is exactly the gap you pointed at. Nothing was sent; the two registries’ answers are in the record.

Case B — task #15, issue 0x8e06e840…7524: VETO(screening) → ALL(technical, economic). Three bodies: a screening body (jurors 4-5, 1-of-2, signed ballots, 15-minute window, REQUIRE_CLEARANCE) and two chambers (jurors 1-3 and 3-5, 2-of-3 each, one-hour windows, on-chain ballots). The screening body blocked twice in one signed batch — explicit clearance — and the four chamber members each sent their own castBallot(issue, round 1, body, Yes); this is the first time on-chain ballots and composition ran outside the local suites. settleRound found the root Yes at the fourth ballot, 828 s before the veto window would have closed: explicit clearance lets the target pass early, and the VETO node never held the result hostage. Appeal window passed, finalize → Final × Decided(Yes), execute → acceptFulfillment(15, 1) paid the worker. The BallotCast events show the two acceptance paths side by side (signed: true for the screening body, false for the chambers) under one counting rule.

Case C — task #16, issue 0x79552e1d…d554: the reject path. The 3-of-5 policy of Case A, a deliverable that answers a different question, three signed No ballots: n − k + 1 blocks, so the body decides No (the approval rule failed, not “the opposite was proven” — the adapter maps No to rejectFulfillment because that is the consumer’s own committed effectNo). Final × Decided(No), execute → rejectFulfillment(16, 1): the submission is Rejected, pendingOf is back to 0, the 0.001 ETH escrow is untouched, nothing was credited to the worker.

What is still local-only is so by construction of this adapter, which files as CONSUMER_FILED: STANDING_ACCEPTANCE, POST_ACK and AUTHORIZED_SUBMITTER bodies need a different relying contract, and an exercised veto (an approve ballot in the screening body) is in the suites but not on chain yet. Everything above — every transaction with block, timestamp and gas, the decoded getResult and getRound reads, the kernel’s submission records — is under revision3 in deployments/sepolia-cases.json of the reference repository; docs/sepolia-runbook.md has the commands.

1 Like