Repository navigation
feat(schemas): acceptance of signed commercial terms binds every member - #8884
KonstantinMirin wants to merge 2 commits into
Conversation
terms_digest is SHA-256 over the JCS bytes of the whole commercial_terms value, and many schemas reachable from commercial-terms.json are already open, so members a party does not recognize can sit inside the signed bytes. The spec never said what accepting such terms means. State the rule in commercial-terms.json, the terms_digest description, check-governance-request.json, and the docs that specify the digest and acceptance: - The digest covers the entire commercial_terms JSON value, every member at every depth, including ext and unrecognized members. - Accepting the terms binds the accepting party to every member. A party that cannot interpret every member must not accept them: a buyer declines with the new decline reason unrecognized_terms, a seller rejects the request with the new error code UNRECOGNIZED_TERMS, and a governance agent denies the check. A member whose schema defines it as opaque, such as context, is interpreted by that definition. - A party computes or verifies the digest over the JSON value as received, never over a re-serialized parsed model. Correct the release notes, What's new in 3.3, and both migration guides, which gave "terms_digest signs exactly those terms" as a reason to keep commercial-terms closed. It stays closed because a minor release never changes a closed schema. Add the media_buy_seller/unrecognized_terms_rejected storyboard: a buy_products purchase carries an undefined member in format_option_refs[0], an open schema, and must fail with UNRECOGNIZED_TERMS; the same purchase without it must succeed. The training agent recorded the member inside the accepted commercial_terms; it now rejects any purchase member the source schemas do not declare.
Spell the decline reason UNRECOGNIZED_TERMS, like the error code. It names the same condition from the buyer's side, and its description now says so. Split the storyboard so the control purchase runs in its own phase before the rejection, and make the rejection phase depend on it. The UNRECOGNIZED_TERMS check is graded only after the identical purchase is proven acceptable; if the control fails, the runner skips the check. Replace the undefined member with adcp_compliance_undefined_member, a name reserved as undefined so that no conformant seller can legitimately interpret it.
Preview approval requiredThis pull request comes from a fork, so Mintlify won't build a preview until someone with write access approves commit |
There was a problem hiding this comment.
Ladon verdict: Escalate to human review
Escalate — gated schema paths require human/CODEOWNERS approval.
This PR modifies five files under static/schemas/source/**, which fall under the repo's hard, path-based approval gate (gated_paths: true). The current review_decision is REVIEW_REQUIRED, not APPROVED, so the gate is not satisfied (decision table row 2).
Gated files requiring review:
- static/schemas/source/core/canonical-proposal.json
- static/schemas/source/enums/error-code.json
- static/schemas/source/enums/proposal-decline-reason.json
- static/schemas/source/governance/check-governance-request.json
- static/schemas/source/media-buy/commercial-terms.json
The reviewer found the change clean: additive Normative surface (new UNRECOGNIZED_TERMS error code + decline_proposals reason, acceptance rule in commercial-terms.json/terms_digest/check-governance-request.json), schema↔docs coherent across all nine edited pages, a minor changeset matching the additive enum/requirement surface, no oneOf regression, and no released-artifact mutation. No blocking or medium findings. The uppercase UNRECOGNIZED_TERMS casing is acknowledged and deferred to #8885 — not a finding.
Despite the clean diff, the gated-paths hard rule forbids auto-approve until a human/CODEOWNERS approval lands. Once review_decision becomes APPROVED, this PR falls through to the normal table and can approve on its clean findings.
Why human review
- Touches gated schema paths under static/schemas/source/** (canonical-proposal.json, error-code.json, proposal-decline-reason.json, check-governance-request.json, commercial-terms.json) and review_decision is REVIEW_REQUIRED, not APPROVED — human/CODEOWNERS review required before merge.
- This PR touches a path under a hard, non-overridable approval gate (static/schemas/source/core/canonical-proposal.json (modified) matches
static/schemas/source/**; static/schemas/source/enums/error-code.json (modified) matchesstatic/schemas/source/**; static/schemas/source/enums/proposal-decline-reason.json (modified) matchesstatic/schemas/source/**; static/schemas/source/governance/check-governance-request.json (modified) matchesstatic/schemas/source/**; static/schemas/source/media-buy/commercial-terms.json (modified) matchesstatic/schemas/source/**) and the current GitHub review decision is 'REVIEW_REQUIRED', not APPROVED. This is a hard gate enforced in code — Ladon cannot auto-approve until a human/CODEOWNERS approval is recorded, regardless of how clean the diff is.
bokelley
left a comment
There was a problem hiding this comment.
Thanks Konstantin. This is the rule that makes open schemas inside signed terms safe, and I want it in the 3.3 RC. I'm approving the rule as written:
- the digest covers every member at every depth, including
ext; - accepting binds you to all of them;
- a party that can't interpret a member refuses;
- the digest is computed over the value as received.
I'm also happy with the correctable classification and the storyboard.
One change before merge: please make the decline reason lowercase, unrecognized_terms, in enums/proposal-decline-reason.json. Every other value in that enum is lowercase snake_case, and the enum is closed. An uppercase value would stay inconsistent through all of 3.x, which #8885 can only fix in 4.0. Keep UNRECOGNIZED_TERMS as the error code in enums/error-code.json, matching the other error codes.
It's still one condition seen from either side, so keep the cross-references. Each vocabulary just keeps its own casing, e.g. "a buyer declines with reason unrecognized_terms; a seller reports error code UNRECOGNIZED_TERMS". The decline-reason spelling also appears in:
- the
commercial-terms.jsonandcanonical-proposal.jsondescriptions; check-governance-request.json, if it names the reason;- the decline-reason
enumDescriptionsentry; - the proposal-negotiation,
decline_proposals,pricing-modelsand migration/release docs; - the changeset.
Ordering with #8866: whichever of the two lands first, the other rebases. #8866 is taking your review now. It drops the "signed commercial terms" structural reason in favour of "held closed until 4.0", so the three docs pages should end up saying the same thing as this PR.
Summary
terms_digestis SHA-256 over the RFC 8785 JCS bytes of the wholecommercial_termsvalue. On main, many of the 92 schemas reachable frommedia-buy/commercial-terms.jsonhave open nodes (core/targeting.json,core/format-option-ref.json,core/ext.json, and others), so members a party doesn't recognize can already sit inside the signed bytes. The spec doesn't say what accepting those terms means for such members. This PR states it:commercial_termsJSON value: every member at every depth, includingextand members the party doesn't recognize.decline_proposalsreasonUNRECOGNIZED_TERMS, a seller rejects the request withUNRECOGNIZED_TERMS, and a governance agent denies through its existing deny path. A member whose schema defines it as opaque, such ascontext, is interpreted by that definition.Changes
enums/error-code.json: newUNRECOGNIZED_TERMS, with description andenumMetadata. I classified itcorrectable: the buyer can remove the member, or send it only to a seller that interprets it, and retry. Nothing about it needs operator intervention, soterminaldidn't fit. It is distinct fromUNSUPPORTED_FEATURE(a field the seller recognizes but doesn't support) andINVALID_REQUEST(a schema failure).scripts/error-code-drift-dispositions.jsonholds it for 3.3, anddocs/snippets/compliance-error-codes.mdxis regenerated.enums/proposal-decline-reason.json: newUNRECOGNIZED_TERMS. Buyer and seller use the sameUNRECOGNIZED_TERMSon purpose, with the same spelling: it is one condition, seen from either side, so it should be one code. That makes it the only uppercase value inproposal-decline-reason, which shows the inconsistency between our error and reason vocabularies. I've suggested addressing that in 4.0 in 4.0: one UPPER_SNAKE_CASE vocabulary for error, reason and outcome codes, defined per protocol and checked by the build #8885.media-buy/commercial-terms.jsondescription, theterms_digestdescription incore/canonical-proposal.json, and theproposaldescription ingovernance/check-governance-request.json.buy_products(the seller side),decline_proposals,check_governance, and a line in pricing models so "ignore unrecognized fields inprice_breakdown" doesn't read as permission to accept terms carrying them.commercial-termsstays closed because "terms_digestsigns exactly those terms". The schema stays closed because a minor release never changes a closed schema (DR-0036), and the pages now state the acceptance rule instead.media_buy_seller/unrecognized_terms_rejected, listed in the media-buy protocol'srequires_scenariosand gated onmedia_buy.lifecycle_toolscontainingbuy_products. The buyer'sbuy_productspurchases become the accepted proposal'scommercial_terms, so the seller is the accepting party there. A control phase first buys the product with a plain purchase and expects a MediaBuy. The rejection phase depends on it and sends the identical purchase with one added member,adcp_compliance_undefined_member, informat_option_refs[0], and expectsUNRECOGNIZED_TERMS. The name is reserved as undefined, so no conformant seller can legitimately interpret it.core/format-option-ref.jsonis open, so the request is schema-valid, and the member is the only difference between the two requests. If the control fails, the runner skips the rejection step (prerequisite_failed) instead of grading it. Nothing in the spec makes a second buy of the same product fail: the two requests use different idempotency keys, and there is no duplicate-buy rule.accept_proposalisn't covered because the buyer sends onlyproposal_idandproposal_terms_digestthere, never the terms.commercial_terms.handleBuyProductsnow rejects any purchase member the source schemas don't declare, at any depth, withUNRECOGNIZED_TERMSanderror.fieldset to its path. The walk isundeclaredMembersinserved-release-fields.tsand reuses that module's schema walk. A unit test covers an undeclared member, anextmember, and an opaquecontextmember.minor(new enum values and a new normative requirement).Not changed
governance/decisions/DR-0036-closed-schemas-stay-closed-within-a-major.md, lines 135 and 136 ("A buyer must not be bound by terms it can't read, andterms_digestsigns exactly these terms"). Brian is rewriting DR-0036 in docs(governance): DR-0036 records open schemas within a major; withdraw DR-0032 and DR-0033 #8858, so I left it alone. With this rule in place, that sentence is no longer a reason to keepcommercial-termsclosed..changeset/pre/withdraw-commercial-terms-features-to-4-0.mdhas the same sentence. It is release history, so I didn't edit it.Testing
All run in a clean worktree off
origin/mainat 0f337b4, on Node 24.21.0:npm run build:schemas: passes.npm run test:schemas: passes.npm run typecheck: passes.npm run build:compliance -- --check: passes.npm run test:compliance-snippets: passes.npm run test:additive-minor: passes (enum additions are allowed in a minor release).npm run test:docs-nav,npm run check:owned-links,npm run lint:schema-links: pass.npm run test:mcp-schema-projection: passes.npm run test:storyboard-*lints: pass.node scripts/lint-error-code-drift.cjs: passes.node tests/json-schema-validation.test.cjs --fileandnode tests/snippet-validation.test.cjs --fileon each of the 9 edited docs pages: pass.node scripts/check-changeset-protocol-scope.cjs origin/mainandnpx @changesets/cli status --since=origin/main: pass, oneminorbump.npx vitest run --config server/vitest.config.ts server/tests/unit/training-agent-served-release-fields.test.ts: 6 of 6 pass./sales, in the same candidate-version mode CI uses: with the training-agent change reverted, the control step passes and only theUNRECOGNIZED_TERMScheck inreject_unrecognized_memberfails (5 of 6 steps pass). With the fix it passes 6 of 6. With the control deliberately broken (an unknown pricing option), the control fails and the rejection step is reported as skipped (prerequisite_failed), not graded.media_buy_seller/compact_direct_buy_lifecyclestill passes 8 of 8./saleshad 196 of 196 applicable storyboards clean and 1422 passing steps (floors 191 and 1400).Merge timing
Intended for 3.3, before the Nov 16 release candidate.