diff --git a/.claude/skills/changelog-gap-check/skill.md b/.claude/skills/changelog-gap-check/skill.md new file mode 100644 index 0000000..13db8ac --- /dev/null +++ b/.claude/skills/changelog-gap-check/skill.md @@ -0,0 +1,124 @@ +# changelog-gap-check + +Check a changelog entry for progressive-discovery ordering and content quality. + +## When to use + +Invoke with `/changelog-gap-check ` where `` is the absolute or repo-relative path to a changelog markdown file (e.g. `changelog/02_Cobalt_B20_seize.md`). + +## What this skill does + +This is a **read-only audit**. It produces a report with reorder suggestions and consolidation recommendations — it does not modify the file. + +### 1. Progressive-discovery check + +For each section (delimited by `##` headings), scan the bullet points and sentences in order. A concept (identifier, acronym, named entity) must be **introduced before it is referenced**. If sentence/bullet N references concept X but concept X is first defined or introduced at sentence/bullet M where M > N, flag it as an ordering violation. + +**How to detect:** +- Extract named concepts: function names, role names, policy names, event names, error names, acronyms, and domain terms that are not common English. +- For each concept, record its first-mention position (section + bullet/sentence index). +- If a reference appears before its introduction within the same section, report it. + +**Output format for each violation:** +``` +REORDER: section "" + - "" (line ~N) + references `X`, first introduced at line ~M ("") + → Suggest: move introduction before first reference +``` + +### 2. Duplicate/redundant content check + +Scan across all sections for bullets or sentences that convey the same information. Flag pairs where: +- The same fact is stated in two places (even with different wording). +- A later bullet restates what an earlier bullet already covered. + +**Output format:** +``` +DUPLICATE: + - line ~N: "" + - line ~M: "" + → Suggest: consolidate into a single bullet, keep in whichever section introduces it first +``` + +### 3. Point-form style recommendations + +For any paragraph-style prose that could be tightened into point-form technical English, suggest a rewrite. Technical English means: +- Active voice, present tense +- One fact per bullet +- No filler words ("it should be noted that", "in order to", "basically") +- Named subjects (not "it" or "this" without antecedent) + +**Output format:** +``` +STYLE: line ~N + Original: "" + Suggested: + - + - +``` + +### 4. Motivation gap detection (what without why) + +Scan for statements that introduce a NEW capability, behavior change, or design decision without explaining the problem it solves or the reason it exists. + +**Symptoms to flag:** +- "allows X to Y" / "enables X" / "adds support for X" without a preceding or following "because" / "so that" / problem statement +- Specs sections that describe HOW a mechanism works (storage layout, gas, ordering, event sequence) without stating WHY it matters to the reader +- Design-decision bullets that state a choice without rationale ("we chose X" with no "because Y") + +**What is NOT a gap (do not flag):** +- A feature whose motivation is explained earlier in the same document (Summary or Motivation section) does not need re-explanation in Specs — unless the Specs section exceeds ~30 lines, at which point a reader skimming only that section loses context +- Standard interface conformance ("ERC-8056 conformant") is a valid WHY when the standard is linked or named +- Backwards-compatibility statements ("unchanged for back-compat") are self-motivating +- Q&A pairs in a "Guarantees and edge cases" section — these answer "how does it behave?" which is their purpose + +**Output format:** +``` +WHY-GAP: line ~N, section "" + Statement: "" + Missing: motivation / problem statement — why does this capability exist? + Hint: +``` + +**Example:** +``` +WHY-GAP: line ~12, section "Summary" + Statement: "The new scheduled path allows issuers to define a new multiplier in advance and activate it at a specific future timestamp." + Missing: motivation — why do issuers need advance scheduling rather than instant updates? + Hint: State the operational driver (e.g., corporate actions require advance notice to exchanges/custodians before the multiplier flips). +``` + +## Output structure + +``` +# Changelog Audit: + +## Progressive Discovery +### Section: + + +## Duplicates + + +## Style Suggestions + + +## Motivation Gaps + + +## Summary +- Ordering violations: N +- Duplicate pairs: N +- Style suggestions: N +- Motivation gaps: N +``` + +## Rules + +- Do NOT edit the file. Report only. +- If the user passes `--fix`, then apply the suggested reorderings and consolidations directly to the file. Still output the report showing what changed. +- Treat markdown tables as atomic units — do not reorder rows within a table for progressive discovery (tables have their own sort order conventions). +- Ignore the audience blockquote (`> **Audience:**...`) — it's introductory framing, not subject to reorder. +- Cross-section references are fine: a concept introduced in an earlier section can be referenced in a later section without violation. +- Only flag ordering issues within the same `##` section. diff --git a/.claude/skills/changelog-grill/SKILL.md b/.claude/skills/changelog-grill/SKILL.md new file mode 100644 index 0000000..385b000 --- /dev/null +++ b/.claude/skills/changelog-grill/SKILL.md @@ -0,0 +1,41 @@ +--- +name: changelog-grill +description: Interview the user relentlessly about a changelog or ADR template until reaching shared understanding. Proposes point-form edits after each round. +--- + +# Changelog Grill + +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. + +## Setup + +1. Read the template file (path provided by user, or pasted content). +2. Read `changelog/TEMPLATE_POINT_FORM.md` for expected structure. +3. Inspect related source, interfaces, tests, and existing changelog entries to verify facts. + +## Protocol + +Walk down each branch of the design tree resolving dependencies between decisions one by one. + +- Ask exactly ONE question per message. Never ask two or more questions in the same output. +- If the question can be answered by exploring the codebase, explore the codebase instead of asking. If there are key details missing in the template suggest them. +- For each question, provide your recommended answer. +- After the user responds, apply the edit to the point-form template immediately, then ask the + next single question. + +Question format: + +``` +❓ ****: <question + context> +➡️ **Recommendation:** <your recommended answer and why> +``` + +## After each answer + +Apply the result directly to the point-form template file. Edits must use point-form technical +English: active voice, present tense, one fact per bullet, named subjects, state WHY alongside WHAT. + +## Completion + +Done when every section has been reviewed, no assumption remains silent, and the user confirms +shared understanding. diff --git a/.claude/skills/technical-document/SKILL.md b/.claude/skills/technical-document/SKILL.md new file mode 100644 index 0000000..b60a258 --- /dev/null +++ b/.claude/skills/technical-document/SKILL.md @@ -0,0 +1,249 @@ +--- +name: technical-document +description: "Convert changelog/TEMPLATE_POINT_FORM.md from point form into a written technical specification. Use the exact template structure. Apply ASD-STE100-inspired Simplified Technical English and Google technical-writing guidance while preserving technical facts, uncertainty, and scope." +version: 2.0.0 +--- + +# Convert the point-form template into a written specification + +Use this skill for one task only: convert the repository template +`changelog/TEMPLATE_POINT_FORM.md` into a complete written specification. + +Do not use this skill to create a generic RFC, design document, technical overview, +or implementation plan. The template is the source of truth for the output structure. + +## Source template + +Read and follow: + +```text +changelog/TEMPLATE_POINT_FORM.md +``` + +Also follow the repository guidance in: + +```text +changelog/AGENTS.md +changelog/README.md +``` + +## Required reading before drafting + +Before writing any output, read every file below in full. These are the bundled, +offline copies of this skill's writing rules — use them as the authoritative source +instead of fetching the external sites they summarize: + +```text +references/template-point-form.md +references/simplified-technical-english.md +references/google-technical-writing.md +``` + +Do not skip this reading pass, and do not substitute a `WebFetch` of the external URLs +named in Steps 5 and 6 for reading these files — the referenced sites may be unreachable, +paywalled, or have drifted from what this skill applies, and the bundled files are the +version this skill is designed against. + +## Exact output structure + +The output must use this structure and heading text in this order: + +```markdown +# [Title] + +- **Feature Name**: [value] +- **Start Date**: [value] +- **Authors**: [value] +- **Title**: [value] + +## Summary + +## Motivation + +## Background + +## Specs + +### Interface Changes + +### Behavioural Changes + +### Examples + +## Design Decisions & Alternatives Considered + +## Migration Steps +``` + +Template conformance is mandatory: + +- Use the exact heading text and heading order. +- Keep all required headings, even when a section contains a TODO. +- Do not add headings such as `Goals`, `Risks`, `Open Questions`, `Decision`, + `Rollout`, or `Appendix`. +- Put content that would normally use those headings into the closest defined section. +- Add headings under `## Specs` only when the source template or user explicitly requires them. +- Do not add generic preambles, summaries, transformation notes, or closing reports. +- Return only the completed specification unless the user explicitly asks for an audit or explanation. + +## Conversion workflow + +### 1. Read the complete input + +Read the entire point-form document before drafting. Extract: + +- Feature name, start date, authors, and title. +- Summary and audience. +- Problem, motivation, and current limitations. +- Background concepts, prior art, standards, and existing patterns. +- Interface changes, including functions, events, errors, signatures, and selectors. +- Behavioral changes, including execution flow, storage, compatibility, and gas effects. +- Examples, expected values, emitted events, and call sequences. +- Design decisions and rejected alternatives. +- Migration steps, deprecations, compatibility, and breaking changes. + +Treat comments in the template as instructions. Do not copy those comments into the output. + +### 2. Map content to the fixed sections + +Use this mapping: + +- Metadata → document metadata. +- Summary bullets → `## Summary`. +- Problem and rationale → `## Motivation`. +- Prerequisites, prior art, standards, and terminology → `## Background`. +- Interface facts → `### Interface Changes`. +- Runtime, storage, gas, and compatibility facts → `### Behavioural Changes`. +- Before/after snippets and call sequences → `### Examples`. +- Chosen approach and rejected options → `## Design Decisions & Alternatives Considered`. +- Integrator adoption and rollout actions → `## Migration Steps`. + +Do not move content to a new heading to make the document easier to organize. + +### 3. Preserve technical fidelity + +- Preserve every fact, number, condition, qualifier, and scope boundary. +- Preserve uncertainty. Keep `may`, `could`, `estimated`, and `unknown` unchanged in strength. +- Do not invent selectors, topic values, signatures, storage slots, gas costs, owners, dates, or compatibility claims. +- Mark missing information with `[TODO: ...]` in the relevant section. +- Mark an inference with `[Inferred — verify: ...]`. +- If a technical value is not verified, write `[TODO: verify against source]`. +- Keep code and signatures exactly as provided unless the user asks for a correction. + +Before citing a function signature, event signature, error selector, topic0, function +selector, or ERC-165 interface ID, verify it against the source or mark it for verification. + +### 3a. Offer to help fill `### Interface Changes` and `### Examples` gaps + +`### Interface Changes` and `### Examples` are the two sections most likely to need facts +the point-form input does not supply in full: exact signatures, selectors, topic0 values, +or a working call sequence. When either section would otherwise ship with a `[TODO: ...]` +or `[TODO: verify against source]` marker, stop before finalizing that section and ask the +user whether they want help closing the gap — for example, whether you should search the +repository (`src/interfaces/**` and related source) to find or verify the missing +signature, selector, or example, or whether they would rather supply the value themselves +or leave the TODO in place. + +- Ask once per document, covering all outstanding interface/example gaps together, rather + than once per gap. +- If the user says yes, read the relevant source before writing the section, and verify + each value you cite rather than guessing. +- If the user declines, or doesn't respond, keep the `[TODO: ...]` markers rather than + inventing a plausible-looking signature, selector, or example. +- Do not ask about gaps elsewhere in the document (Summary, Motivation, Background, Design + Decisions, Migration Steps) — only Interface Changes and Examples warrant this offer. + +### 4. Write the specification + +Convert point-form fragments into complete technical prose. Keep the meaning and +technical detail, but improve structure, readability, and consistency. + +Use tables for mappings when the input contains multiple comparable entries. Use +numbered lists for migration steps. Use code blocks for verified code or call sequences. + +### 5. Apply Simplified Technical English principles + +Apply the rules in `references/simplified-technical-english.md` (read in the required +reading pass above). That file is the source of truth for this step — do not fetch the +public `asd-ste100-skill` repository it summarizes. + +Apply these rules: + +- Prefer active voice and name the actor when the actor is known. +- Use one clear action per sentence. +- Keep procedures near 20 words per sentence and descriptions near 25 words when possible. +- Split long or compound sentences when splitting preserves precision. +- Do not use semicolons in procedural text. +- Keep subjects, verbs, articles, and conditions explicit. +- Put conditions before instructions. +- Replace ambiguous phrasal verbs such as `spin up` and `kick off` with plain verbs. +- Use one term for one technical concept. Do not rotate synonyms. +- Prefer direct, common words over formal or vague alternatives. +- Preserve technical terms when simplifying them would reduce accuracy. + +Apply structural rules confidently. Treat dictionary-specific lexical rules as guidance +only. Never claim that the output is officially ASD-STE100 compliant, and never reproduce +the official ASD-STE100 dictionary. + +### 6. Apply Google technical-writing guidance + +Apply the checklist in `references/google-technical-writing.md` (read in the required +reading pass above). That file is the source of truth for this step — do not fetch +developers.google.com. + +Apply these rules: + +- Write for the technical reader’s role and existing knowledge. +- State the purpose and scope in `Summary` and `Motivation`. +- Use clear, descriptive sentence-case headings from the template. +- Put important information before supporting detail. +- Use plain, globally understandable English. +- Define acronyms and unfamiliar domain terms at first use. +- Use numbered lists for ordered steps and bullets for unordered information. +- Avoid idioms, slang, cultural references, buzzwords, and marketing language. +- Replace vague claims with measurements when the input provides measurements. +- Use descriptive link text and unambiguous dates. + +### 7. Review before returning + +Check all of the following: + +- The output matches the exact template heading structure. +- No template heading is missing, renamed, duplicated, or reordered. +- No unsupported technical fact was added. +- No source fact, qualifier, or condition was removed. +- The output contains no invented selector, signature, storage detail, or compatibility claim. +- Interface changes are separated from behavioral changes. +- Examples contain only supplied or verified values. +- Migration steps identify compatibility and breaking changes when the input provides them. +- Missing information uses TODO markers. +- Any Interface Changes/Examples gap was offered to the user per Step 3a before shipping a TODO. +- Active voice, terminology consistency, sentence clarity, and plain language were applied. + +## Output rules + +Return only the written specification by default. Do not explain the conversion and do +not add headings outside the template. + +If the user asks for an audit, return the conforming specification first. Add the audit +after a plain-text separator such as `--- Review ---`, and do not treat the audit as part +of the specification. + +## Boundaries + +This skill will not: + +- Generate other document types. +- Invent technical facts or evidence. +- Claim official ASD-STE100 compliance. +- Reproduce the ASD-STE100 approved-word dictionary. +- Verify repository behavior without reading the relevant source. +- Manufacture selectors, signatures, storage layouts, or migration guarantees. + +## References + +- `references/google-technical-writing.md` +- `references/template-point-form.md` +- `references/simplified-technical-english.md` +- `examples/point-form-input.md` +- `examples/generated-spec.md` diff --git a/.claude/skills/technical-document/examples/generated-spec.md b/.claude/skills/technical-document/examples/generated-spec.md new file mode 100644 index 0000000..1cdb6c9 --- /dev/null +++ b/.claude/skills/technical-document/examples/generated-spec.md @@ -0,0 +1,57 @@ +# Add a temporary worker pool for release builds + +- **Feature Name**: temporary_release_worker_pool +- **Start Date**: 2026-08-18 +- **Authors**: [TODO: provide author] +- **Title**: Add a temporary worker pool for release builds + +## Summary + +Release periods increase build wait time because normal builds and release builds +share one worker pool. This specification defines a temporary worker pool for +release builds. + +## Motivation + +The change reduces release-build wait time without adding permanent workers. The +current wait-time baseline is `[TODO: provide the baseline]`. + +## Background + +One shared worker pool handles normal builds and release builds. The release-period +threshold and available worker capacity are `[TODO: provide the missing values]`. + +## Specs + +### Interface Changes + +No interface changes are currently identified. + +### Behavioural Changes + +The system adds a temporary worker pool for release builds. The system limits the +pool size so release builds cannot use all available capacity. The temporary pool +could starve normal builds. + +### Examples + +Test the pool with one repository before expanding it. The expected wait-time target +is `[TODO: provide the target]`. + +## Design Decisions & Alternatives Considered + +The preferred approach adds a temporary release worker pool. This approach avoids +permanent worker capacity but adds queue configuration and a starvation risk. + +The alternative keeps one pool and increases its size. This approach preserves the +current queue model but adds permanent worker capacity. + +## Migration Steps + +1. Measure the current release-build wait time. +2. Configure the temporary pool for one repository. +3. Monitor normal-build wait time during the pilot. +4. Expand the pool after validation. + +Disable the temporary pool to roll back the change. The rollback owner and trigger +are `[TODO: provide the missing values]`. diff --git a/.claude/skills/technical-document/examples/point-form-input.md b/.claude/skills/technical-document/examples/point-form-input.md new file mode 100644 index 0000000..22d7941 --- /dev/null +++ b/.claude/skills/technical-document/examples/point-form-input.md @@ -0,0 +1,49 @@ +# Point-form input for TEMPLATE_POINT_FORM + +- **Feature Name**: temporary_release_worker_pool +- **Start Date**: 2026-08-18 +- **Authors**: [TODO: provide author] +- **Title**: Add a temporary worker pool for release builds + +## Summary + +- Release periods increase build wait time. +- Normal builds and release builds share one worker pool. + +## Motivation + +- Reduce release-build wait time. +- Avoid adding permanent workers. + +## Background + +- One shared worker pool handles normal builds and release builds. +- The current wait-time baseline is not available. + +## Specs + +### Interface Changes + +- No interface changes are currently identified. + +### Behavioural Changes + +- Add a temporary worker pool for release builds. +- Limit the release-pool size. +- The temporary pool could starve normal builds. + +### Examples + +- Test the pool with one repository before expanding it. + +## Design Decisions & Alternatives Considered + +- Preferred approach: add a temporary release worker pool. +- Alternative: keep one pool and increase its size. +- Mitigation: set a maximum release-pool size. + +## Migration Steps + +- Test with one repository. +- Expand after validation. +- The target wait time is not defined. diff --git a/.claude/skills/technical-document/references/google-technical-writing.md b/.claude/skills/technical-document/references/google-technical-writing.md new file mode 100644 index 0000000..4b847cd --- /dev/null +++ b/.claude/skills/technical-document/references/google-technical-writing.md @@ -0,0 +1,49 @@ +# Google technical-writing checklist + +Use this checklist as a practical guide. It summarizes public Google technical-writing +and developer documentation guidance. It is not a replacement for project-specific style rules. + +## Audience and scope + +- Identify the reader's role and existing knowledge. +- State what the document covers. +- State what the document does not cover when readers could reasonably expect it. +- Explain what the reader should know or do after reading the document. + +## Organization + +- Put the key information in the opening summary. +- Use a logical order based on reader needs. +- Use descriptive, hierarchical headings. +- Use sentence case for titles and headings. +- Do not skip heading levels. +- Introduce a concept before asking the reader to use it. +- Use progressive disclosure for long documents. + +## Voice and language + +- Prefer active voice. +- Address the reader as `you` when giving instructions. +- Use the imperative for procedures. +- Use plain, globally understandable language. +- Avoid idioms, slang, and culturally specific references. +- Define unfamiliar terms and acronyms at first use. +- Prefer specific verbs over vague verbs such as `do`, `occur`, or `happen`. +- Replace subjective claims with data. + +## Formatting + +- Use numbered lists for ordered steps. +- Use bullets for unordered items. +- Use code formatting for commands, identifiers, and code elements. +- Use descriptive link text. +- Add alt text when including images or diagrams. +- Use unambiguous dates such as `2026-08-18`. + +Sources: + +- https://developers.google.com/style +- https://developers.google.com/style/highlights +- https://developers.google.com/style/headings +- https://developers.google.com/tech-writing/one/audience +- https://developers.google.com/tech-writing/one/documents diff --git a/.claude/skills/technical-document/references/simplified-technical-english.md b/.claude/skills/technical-document/references/simplified-technical-english.md new file mode 100644 index 0000000..d6b95a8 --- /dev/null +++ b/.claude/skills/technical-document/references/simplified-technical-english.md @@ -0,0 +1,29 @@ +# Simplified Technical English principles + +This reference applies principles from the public skill: + +https://github.com/danyuchn/asd-ste100-skill + +It does not reproduce the official ASD-STE100 dictionary and does not claim official +ASD-STE100 compliance. + +## Apply these structural rules + +- Prefer active voice when the actor is known. +- Use one clear action per sentence. +- Keep procedures near 20 words per sentence and descriptions near 25 words when possible. +- Split long or compound sentences when precision is preserved. +- Do not use semicolons in procedural text. +- Keep subjects, verbs, articles, and conditions explicit. +- Put conditions before instructions. +- Replace ambiguous phrasal verbs with plain verbs. +- Use one term for one technical concept. +- Preserve every number, condition, qualifier, and scope boundary. + +## Apply lexical rules carefully + +Use plain, common words and consistent terminology. Treat dictionary-specific rules as +guidance only because the official approved-word dictionary is not part of this repository. + +Never convert `may` to a fact, remove a safety condition, or shorten text when the change +would lose technical precision. diff --git a/.claude/skills/technical-document/references/template-point-form.md b/.claude/skills/technical-document/references/template-point-form.md new file mode 100644 index 0000000..ac6dcb2 --- /dev/null +++ b/.claude/skills/technical-document/references/template-point-form.md @@ -0,0 +1,40 @@ +# Point-form template mapping + +The canonical source template is: + +```text +changelog/TEMPLATE_POINT_FORM.md +``` + +The skill converts the template into prose without changing its structure. + +## Section mapping + +| Source content | Written specification section | +| --- | --- | +| Feature name, start date, authors, title | Document metadata | +| Summary bullets | `## Summary` | +| Problem and rationale | `## Motivation` | +| Prior art, standards, prerequisites, terminology | `## Background` | +| Functions, events, errors, signatures, selectors | `### Interface Changes` | +| Execution, storage, gas, compatibility | `### Behavioural Changes` | +| Before/after snippets and call sequences | `### Examples` | +| Chosen approach and rejected options | `## Design Decisions & Alternatives Considered` | +| Integrator adoption and compatibility actions | `## Migration Steps` | + +## Required headings + +```text +## Summary +## Motivation +## Background +## Specs +### Interface Changes +### Behavioural Changes +### Examples +## Design Decisions & Alternatives Considered +## Migration Steps +``` + +Do not add headings such as `Goals`, `Risks`, `Open Questions`, `Decision`, or +`Rollout`. Place that content in the closest required section. diff --git a/.gitignore b/.gitignore index 5372477..97a2200 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ __pycache__/ *.swp *.swo .idea/ -.vscode/ \ No newline at end of file +.vscode/ +changelog_template/ \ No newline at end of file diff --git a/changelog/02_Cobalt_B20Asset_multiplier.md b/changelog/02_Cobalt_B20Asset_multiplier.md index 4f9282e..60751b7 100644 --- a/changelog/02_Cobalt_B20Asset_multiplier.md +++ b/changelog/02_Cobalt_B20Asset_multiplier.md @@ -1,144 +1,186 @@ -# B20 Asset: Beryl to Cobalt (ERC-8056) migration +# Schedule Multiplier Updates (ERC-8056) -> **Audience:** teams already integrated against the B20 Asset multiplier surface on Beryl (live -> today). This note covers only the multiplier and ERC-8056 changes landing at the Cobalt hardfork. +- **Feature Name**: Scheduled Multiplier +- **Start Date**: 2026-08-17 +- **Authors**: Markus +- **Title**: Schedule Multiplier Updates (ERC-8056) ## Summary -At Cobalt, the B20 Asset multiplier surface becomes [ERC-8056 ("Scaled UI Amount")](https://eips.ethereum.org/EIPS/eip-8056) -conformant and gains a scheduled multiplier setter for corporate actions. Nothing you call today -breaks: every Beryl selector, event topic, and error keeps its exact 4-byte selector or topic0 and -stays dialable at Cobalt. The deprecations below are advisory, not enforced. +This change introduces a scheduled multiplier setter for B20 Asset issuers running corporate actions. The multiplier setter moves from an instant path to a scheduled path aligned with ERC-8056. The change applies only to B20 Asset in the Cobalt hardfork. -To migrate, adopt the canonical ERC-8056 names (`uiMultiplier`, `toUIAmount`/`fromUIAmount`, -`balanceOfUI`, `totalSupplyUI`), and move routine multiplier changes from the instant -`updateMultiplier(uint256)` to the scheduled `updateUIMultiplier(uint256,uint256)`. +Two audiences are affected. Issuers and operators own the write path and use `updateUIMultiplier` to schedule a multiplier change, `cancelUIMultiplierUpdate` to clear a pending update, and the retained `updateMultiplier` instant setter as an emergency failsafe. All three write functions require `OPERATOR_ROLE`. Integrators, indexers, and custodians own the read and event path. They read the pending schedule through `newUIMultiplier()` and `effectiveAt()`, prefer the new `UIMultiplierUpdated` event over the deprecated `MultiplierUpdated` event, and handle lazy maturation of the multiplier flip. -Cobalt hasn't gone live yet. Until it activates, only the Beryl surface exists on-chain. +The scheduled setter enables two corporate action use cases: stock splits (both forward and reverse) and in-kind dividends. Forward splits and reinvested dividends are value-neutral to raw venues and do not require an on-chain halt. Reverse splits are not value-neutral. Operators should pause `PausableFeature.TRANSFER` across the flip window for reverse splits, and should similarly bracket any instant `updateMultiplier` call used for a reverse-adjacent change. The legacy `updateMultiplier` instant setter is retained as an emergency failsafe to correct a wrong scheduled value. -## Mapping table +## Motivation -The selectors and topic0s below are the real values from the frozen ABIs: `abi/v1.rs` for Beryl, -`abi/v2.rs` for Cobalt. Every Beryl symbol keeps its selector at Cobalt. +Before this change, B20 Asset did not have a scheduled setter. Multiplier changes used only the instant `updateMultiplier` path. Corporate actions such as stock splits and in-kind dividends require advance notice. Exchanges, custodians, and off-chain accounting systems must prepare before the multiplier flips. An instant setter forces every downstream system to react at write time, which is not operable at issuer scale. A scheduled setter lets issuers commit to a target multiplier and a future effective timestamp on-chain. Downstream systems can read the pending update and prepare before it takes effect. This change conforms to ERC-8056, which defines a standard for scheduling changes to a real-world asset token. -### Functions +## Background -| Beryl symbol (selector) | Cobalt canonical (selector) | Status | Why | +ERC-8056 (https://eips.ethereum.org/EIPS/eip-8056) defines a standard for scheduling changes to a real-world asset token. B20 Asset is an RWA token standard that conforms to the ERC-20 specification. Prior to this change, B20 Asset provided these functions: + +- `updateMultiplier(uint256 newMultiplier)`: applies the multiplier immediately +- `toScaledBalance(uint256)` and `toRawBalance(uint256)`: legacy read and conversion aliases that predate the ERC-8056 naming + +## Specs + +### Interface Changes + +The following tables describe new, renamed, and deprecated symbols. Selector and topic0 values are verified against the implementation. + +#### Functions + +| Symbol | Selector | Status | Notes | | --- | --- | --- | --- | -| `multiplier()` `0x1b3ed722` | `uiMultiplier()` `0xa60bf13d` | deprecated-name-kept / new alias | ERC-8056 core naming. Both return the same effective multiplier. `multiplier()` stays. | -| `toScaledBalance(uint256)` `0x04f04c99` | `toUIAmount(uint256)` `0x3248d4ff` | deprecated-dialable / new | ERC-8056 Conversion extension. Byte-identical behavior. | -| `toRawBalance(uint256)` `0x0ca06c44` | `fromUIAmount(uint256)` `0x65cd9b3c` | deprecated-dialable / new | ERC-8056 Conversion extension. Byte-identical behavior. | -| `scaledBalanceOf(address)` `0x1da24f3e` | `balanceOfUI(address)` `0x437a9958` | deprecated-name-kept / new alias | ERC-8056 Balances extension. Alias, same value. | -| `updateMultiplier(uint256)` `0x5ffe6146` | `updateUIMultiplier(uint256,uint256)` `0x628e600f` | deprecated-dialable / new (not 1:1) | The canonical path is now the scheduled setter. The instant setter remains as an emergency failsafe. | -| — | `newUIMultiplier()` `0xdc767007` | new | ERC-8056 pending-schedule read. | -| — | `effectiveAt()` `0x97a4064f` | new | ERC-8056 pending-schedule read (flip timestamp). | -| — | `totalSupplyUI()` `0x9bea6429` | new | ERC-8056 Balances extension. | -| — | `cancelUIMultiplierUpdate()` `0x2c97a0f0` | new | Cancels the single live pending update. | -| — | `MAX_UI_MULTIPLIER()` `0x785c0cf0` | new | Reads the multiplier ceiling (`type(uint128).max`) without risking the revert path. | -| — | `supportsInterface(bytes4)` `0x01ffc9a7` | new | ERC-165 feature detection. | - -`OPERATOR_ROLE()` `0xf5b541a6`, `WAD_PRECISION()` `0x664808a8`, `announce(...)` `0x595135dd`, -`isAnnouncementIdUsed(string)` `0xc0da474e`, `batchMint(...)` `0x68573107`, -`extraMetadata(string)` `0x4ddf9da0`, and `updateExtraMetadata(string,string)` `0xb2851ef5` carry -over unchanged. - -### Events - -| Beryl event (topic0) | Cobalt canonical (topic0) | Status | Why | +| `updateUIMultiplier(uint256,uint256)` | `0x628e600f` | new | Canonical scheduled setter for corporate actions. | +| `cancelUIMultiplierUpdate()` | `0x2c97a0f0` | new | Cancels the single live pending update. | +| `newUIMultiplier()` | `0xdc767007` | new | ERC-8056 pending-schedule read (target multiplier). | +| `effectiveAt()` | `0x97a4064f` | new | ERC-8056 pending-schedule read (flip timestamp). | +| `totalSupplyUI()` | `0x9bea6429` | new | ERC-8056 Balances extension. | +| `MAX_UI_MULTIPLIER()` | `0x785c0cf0` | new | Reads the multiplier ceiling (`type(uint128).max`), letting callers validate a proposed multiplier before scheduling without triggering the `InvalidMultiplier` revert path. | +| `supportsInterface(bytes4)` | `0x01ffc9a7` | new | ERC-165 feature detection. | +| `uiMultiplier()` | `0xa60bf13d` | new alias | ERC-8056 core naming. Aliases `multiplier()`; returns the same effective value. | +| `balanceOfUI(address)` | `0x437a9958` | new alias | ERC-8056 Balances extension. Aliases `scaledBalanceOf(address)`; returns the same value. | +| `toUIAmount(uint256)` | `0x3248d4ff` | new | ERC-8056 Conversion extension. Byte-identical to `toScaledBalance`. | +| `fromUIAmount(uint256)` | `0x65cd9b3c` | new | ERC-8056 Conversion extension. Byte-identical to `toRawBalance`. | +| `multiplier()` | `0x1b3ed722` | unchanged (canonical name) | Canonical B20 name; `uiMultiplier()` is the ERC-8056 alias. | +| `scaledBalanceOf(address)` | `0x1da24f3e` | unchanged (canonical name) | Canonical B20 name; `balanceOfUI(address)` is the ERC-8056 alias. | +| `toScaledBalance(uint256)` | `0x04f04c99` | deprecated-dialable | Prefer `toUIAmount(uint256)`. Byte-identical behavior. | +| `toRawBalance(uint256)` | `0x0ca06c44` | deprecated-dialable | Prefer `fromUIAmount(uint256)`. Byte-identical behavior. | +| `updateMultiplier(uint256)` | `0x5ffe6146` | deprecated-dialable | Retained as emergency failsafe. Instant setter; clears any live pending update. Prefer scheduled `updateUIMultiplier`. | + +#### Events + +| Symbol | Topic0 | Status | Notes | | --- | --- | --- | --- | -| `MultiplierUpdated(uint256)` | `UIMultiplierUpdated(uint256,uint256,uint256)` | deprecated-still-emitted / new | ERC-8056 canonical event. The instant setter emits both events. The scheduled setter emits only `UIMultiplierUpdated`. | -| — | `UIMultiplierUpdateCancelled(uint256,uint256)` | new | Signals a cleared pending update. | +| `UIMultiplierUpdated(uint256,uint256,uint256)` | `0x2205df4534432b2f60654a3fdb48737ffdaf3e9edb1a498bd985bc026b15b055` | new | ERC-8056 canonical multiplier-change event. Parameters are `(oldMultiplier, newMultiplier, effectiveAtTimestamp)`. Emitted by both setters; the instant setter stamps `effectiveAtTimestamp = block.timestamp`. | +| `UIMultiplierUpdateCancelled(uint256,uint256)` | `0x883856335ba5f60c18b9817c4505d3c7d3f6223dcf39516b30c508c46a5e1cad` | new | Signals a cleared pending update (via cancel or a superseding instant setter). | +| `MultiplierUpdated(uint256)` | `0x4dbe4840d7465bd162f67814cea0b519567a2e0e578bcde61e7f4ced361e5a3d` | deprecated-still-emitted | Legacy event. Emitted only by the instant setter (`updateMultiplier`) alongside `UIMultiplierUpdated`. The scheduled setter emits only `UIMultiplierUpdated`. | -### Errors +#### Errors -| Beryl error (selector) | Cobalt (selector) | Status | Why | +| Symbol | Selector | Status | Notes | | --- | --- | --- | --- | -| `InvalidMultiplier()` `0x6f12f3dc` | `InvalidMultiplier()` `0x6f12f3dc` | present on Beryl already | Zero or above-ceiling guard. Now also thrown by `updateUIMultiplier`. | -| — | `EffectiveAtInPast(uint256)` `0x14119cf6` | new | Thrown when `effectiveAt <= block.timestamp`. | -| — | `EffectiveAtTooFar(uint256)` `0x1ce214fa` | new | Thrown when `effectiveAt > type(uint64).max`. | -| — | `UIMultiplierUpdateExists(uint256)` `0x4481a68e` | new | Thrown when a live pending update already exists. | -| — | `UIMultiplierUpdateDoesNotExist()` `0xa7d6a5ca` | new | Thrown when you cancel with no live pending update. | - -## New at Cobalt: adopt these - -### Scheduled-update lifecycle - -`updateUIMultiplier(newMultiplier, effectiveAt)` is the canonical path for corporate actions, such -as stock splits and reinvested dividends. Only one pending update can be live at a time. - -1. **Schedule**: call `updateUIMultiplier(newMultiplier, effectiveAt)`. This requires - `OPERATOR_ROLE`, and `effectiveAt` must be strictly in the future. -2. **Read the pending update**: while it's live, `newUIMultiplier()` returns the scheduled target, - `effectiveAt()` returns the flip timestamp, and `uiMultiplier()` / `multiplier()` still return - the current value. -3. **Let it mature**: once `block.timestamp >= effectiveAt`, `uiMultiplier()` / `multiplier()` flip - on read. No event fires at maturation. -4. **Or cancel it**: `cancelUIMultiplierUpdate()` clears a live pending update and emits - `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)`. - -To reorder overlapping actions, cancel and reschedule atomically in one announcement: -`announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. - -### ERC-8056 view aliases - -- `uiMultiplier()` returns the same value as `multiplier()`. -- `toUIAmount(raw)` returns the same value as `toScaledBalance(raw)`. `fromUIAmount(ui)` returns the - same value as `toRawBalance(ui)`. -- `balanceOfUI(account)` returns the same value as `scaledBalanceOf(account)`. -- `totalSupplyUI()` equals `totalSupply() * uiMultiplier() / WAD_PRECISION`. - -### Bound getter - -`MAX_UI_MULTIPLIER()` returns `type(uint128).max`, the ceiling both setters enforce. This is the -overflow guard that keeps `balance * multiplier` inside `uint256`. - -## `updateMultiplier(uint256)` remains as an instant admin failsafe - -`updateMultiplier(uint256)` sets the multiplier immediately and clears any live pending update. It's -a deprecated admin failsafe, kept for tech debt and emergency overrides, not routine use: use it to -instantly reverse a scheduling mistake, and pair it with pausing in most cases. - -## Guarantees and edge cases - -**Q: A scheduled update can be canceled. How do external consumers detect the cancellation?** -`cancelUIMultiplierUpdate()` emits `UIMultiplierUpdateCancelled(cancelledMultiplier, -cancelledEffectiveAt)` (topic0 `0x8838…1cad`); so does the instant setter, when it supersedes a live -pending update. Watch that topic to retract a pending flip you previously staged from -`UIMultiplierUpdated`. - -**Q: If the admin uses the instant failsafe, how do off-chain indexers keep a linear, gap-free -UI-multiplier lifecycle?** -The instant `updateMultiplier(uint256)` emits both the deprecated `MultiplierUpdated(uint256)` and -the ERC-8056 `UIMultiplierUpdated(old, new, block.timestamp)` (and, if it clears a live pending -update, `UIMultiplierUpdateCancelled` first). Every multiplier change, scheduled or emergency, -appears on the single `UIMultiplierUpdated` stream, so following that one event never misses a -change. The legacy `MultiplierUpdated` topic stays available for indexers that haven't migrated. - -**Q: How do I tell a live pending update apart from one that already matured, or none at all?** -A pending update is live if `effectiveAt() > block.timestamp`. While it's live, `newUIMultiplier()` -returns the scheduled target, which differs from `uiMultiplier()`. After maturation, -`uiMultiplier()` already reflects the new value, `newUIMultiplier() == uiMultiplier()`, and -`effectiveAt()` stays at the now-past flip timestamp until the next schedule, instant update, or -cancel overwrites it. So a nonzero `effectiveAt()` that's `<= block.timestamp` means "already -applied," not "pending." If no update has ever been scheduled, `effectiveAt() == 0`. - -**Q: What happens if I schedule an update while one is already pending?** -It reverts `UIMultiplierUpdateExists(effectiveAt)`, but only a live pending update blocks the call. -A matured (stale) pending update is silently folded into the current multiplier and overwritten. To -replace a live schedule, call `cancelUIMultiplierUpdate()` then `updateUIMultiplier(...)`, atomically, -via `announce`. - -**Q: What are the bounds on `effectiveAt`?** -It must be strictly in the future: `effectiveAt <= block.timestamp` reverts -`EffectiveAtInPast(effectiveAt)`. It must also fit the on-chain field: -`effectiveAt > type(uint64).max` reverts `EffectiveAtTooFar(effectiveAt)`. - -**Q: What are the bounds on the multiplier?** -`0 < newMultiplier <= MAX_UI_MULTIPLIER()` (`type(uint128).max`). Zero or above reverts -`InvalidMultiplier()`. This applies to both `updateUIMultiplier` and `updateMultiplier`. You can -read the ceiling from `MAX_UI_MULTIPLIER()` without risking the revert. - -**Q: Do raw balances or `Transfer` semantics change?** -No. The multiplier is purely cosmetic: it rescales only the UI/scaled view. `balanceOf`, -`transfer`, `totalSupply`, and `Transfer` stay raw, and no multiplier change, scheduled or instant, -affects them. Only the `*UI` / scaled reads move. +| `EffectiveAtInPast(uint256)` | `0x14119cf6` | new | Thrown when `effectiveAt <= block.timestamp`. | +| `EffectiveAtTooFar(uint256)` | `0x1ce214fa` | new | Thrown when `effectiveAt > type(uint64).max`. | +| `UIMultiplierUpdateExists(uint256)` | `0x4481a68e` | new | Thrown when a live pending update already exists. | +| `UIMultiplierUpdateDoesNotExist()` | `0xa7d6a5ca` | new | Thrown when cancel is called with no live pending update. | +| `InvalidMultiplier()` | `0x6f12f3dc` | unchanged | Error symbol and selector unchanged. Zero or above-ceiling guard. Now also thrown by `updateUIMultiplier`, and newly thrown by `updateMultiplier` for `newMultiplier > type(uint128).max`. Pre-Cobalt `updateMultiplier` rejected only zero. See Compatibility behavior under Behavioural Changes. | + +#### Interface IDs advertised via `supportsInterface` + +| Interface ID | Interface | Status | +| --- | --- | --- | +| `0x01ffc9a7` | `IERC165` | new advertisement | +| `0xa60bf13d` | `IScaledUIAmount` (ERC-8056 core) | new advertisement | +| `0x4bd27648` | `IScaledUIAmountNewUIMultiplier` (ERC-8056 pending) | new advertisement | +| `0xd890fd71` | `IScaledUIAmountBalances` (ERC-8056 optional) | new advertisement | +| `0x57854fc3` | `IScaledUIAmountConversion` (ERC-8056 optional) | new advertisement | + +ERC-8056 conformance note: The optional `TransferWithUIAmount` event is intentionally not implemented. Scaled balances are derivable from the raw `Transfer` log and the active multiplier, so the event is redundant (see `docs/B20/Asset.md`). + +### Behavioural Changes + +#### Old Behavior + +The `updateMultiplier(uint256)` function applied the multiplier immediately. The change emitted the deprecated `MultiplierUpdated(uint256)` event. + +#### New Behavior + +The `updateUIMultiplier(uint256 newMultiplier, uint256 effectiveAt)` function is the canonical path for routine corporate actions. The caller schedules one pending multiplier update for a future timestamp. The pending update becomes effective lazily on read when `block.timestamp >= effectiveAt`. No extra event fires at maturation time. Off-chain systems must read `uiMultiplier()` or watch the pending schedule. The `newUIMultiplier()` and `effectiveAt()` functions expose the live pending update. The `cancelUIMultiplierUpdate()` function clears the live pending update and emits `UIMultiplierUpdateCancelled(uint256,uint256)`. + +#### Live Pending Definition + +A pending update is **live** while `effectiveAt > block.timestamp` and **matured** once `effectiveAt <= block.timestamp`. The `updateUIMultiplier` function reverts with `UIMultiplierUpdateExists` only against a **live** pending update. A matured pending update does **not** block a new schedule; it is folded first (see Maturation). The `cancelUIMultiplierUpdate` function reverts with `UIMultiplierUpdateDoesNotExist` when there is no live pending update, including when the only pending update has already matured. + +#### Maturation and Materialization + +After `effectiveAt`, reads **compute** the flipped value on the fly. Storage slot 1 (current multiplier) is **not** written at maturation. The matured value is "folded" into slot 1 only on the **next** `updateUIMultiplier`, `updateMultiplier`, or `cancelUIMultiplierUpdate` call. This fold emits **no** event. + +While matured-but-unfolded: `newUIMultiplier()` mirrors `uiMultiplier()` (both return the matured value, **not** 0), and `effectiveAt()` retains its now-past timestamp (**not** reset to 0) until the next setter folds it. + +Integration guidance: Detect a live pending update via `effectiveAt() > block.timestamp`. Never test `effectiveAt() == 0`. + +#### Compatibility Behavior + +The `updateMultiplier(uint256)` function remains callable as a deprecated instant failsafe. It newly reverts with `InvalidMultiplier` for `newMultiplier > type(uint128).max`. Pre-Cobalt it rejected only zero; the ceiling is added in this change so `balance * multiplier` stays within `uint256` (matching the scheduled setter). The bound (~`3.4e20`× as a WAD multiplier) is unreachable for realistic corporate actions. This is a precise-guarantee note, not a practical breaking change. + +The instant setter applies the multiplier immediately and clears any pending update. If it clears a **live** pending update, it emits `UIMultiplierUpdateCancelled(...)` first, then emits the legacy `MultiplierUpdated(uint256)` event and the canonical `UIMultiplierUpdated(uint256,uint256,uint256)` event. If it clears a **matured** pending update, it folds the matured value silently (**no** `UIMultiplierUpdateCancelled`), then emits `MultiplierUpdated(uint256)` and `UIMultiplierUpdated(uint256,uint256,uint256)`. + +#### Access Control + +All three write functions — `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier` — require `OPERATOR_ROLE`. This role is pre-existing (not introduced by this change) and already gates `announce`. + +#### Pause Interaction + +No new `PausableFeature` is added. The `updateUIMultiplier`, `cancelUIMultiplierUpdate`, and `updateMultiplier` functions are not subject to any pause vector. For a reverse split (not value-neutral — see Summary), operators should manually pause `TRANSFER` across the flip window (see `docs/B20/Asset.md`). The instant `updateMultiplier` bypasses the scheduling window entirely, so a reverse-adjacent instant change should likewise be pause-bracketed. + +#### Gas Cost Implications + +Every scaled-view read (`uiMultiplier`, `multiplier`, `balanceOfUI`, `scaledBalanceOf`, `toUIAmount`, `fromUIAmount`, `totalSupplyUI`) now includes an extra `SLOAD` for the pending slot plus a `block.timestamp` compare. Raw `balanceOf` is unchanged. + +#### Storage Layout Changes + +A new field `PendingMultiplier pending` is appended to the `base.b20.asset` ERC-7201 namespace. + +- Namespace location: `0xfdc6d4552d1286ade4d9facdbf0fb50d2ec9b89a90e104f26fd277585e374b00` +- Placed at `PENDING_OFFSET = 4` + +The field is packed into a single 256-bit slot: + +- Bits 0-127: `uint128 multiplier` (target) +- Bits 128-191: `uint64 effectiveAt` (flip timestamp) +- Bits 192-255: unused (32 bytes free for future packing) + +This is an additive change. Pre-existing offsets 0-3 are unchanged: + +- Offset 0: `uint8 decimals` +- Offset 1: `uint256 multiplier` (stored `0` still interpreted as `WAD_PRECISION` on read) +- Offset 2: `mapping usedAnnouncementIds` +- Offset 3: `mapping extraMetadata` + +The layout must match the `base/base` Rust precompile slot-for-slot (AGENTS.md invariant). + +#### ERC-8056 View Aliases + +Alias mappings (`uiMultiplier`↔`multiplier`, `balanceOfUI`↔`scaledBalanceOf`, `toUIAmount`↔`toScaledBalance`, `fromUIAmount`↔`toRawBalance`) are listed in the Functions table. Each returns the same value as its canonical counterpart. The `totalSupplyUI()` function equals `totalSupply() * uiMultiplier() / WAD_PRECISION`. + +#### Edge Cases and Precision + +Raw balances are **canonical** and are never rewritten by a multiplier flip. A flip only changes the derived scaled/UI view. + +Scaled views are computed as `raw * multiplier / WAD_PRECISION`, floored (integer division). The `fromUIAmount` / `toRawBalance` functions are also floored, so the round-trip is lossy by up to one unit (1 ULP) when `multiplier != WAD_PRECISION`. + +A deep **reverse split** can make floored dust economically visible at low decimals. Prefer 18 decimals for equities so it stays noise (see `docs/B20/Asset.md`). + +Scheduling boundary: `effectiveAt` must be strictly in the future (`effectiveAt <= block.timestamp` reverts `EffectiveAtInPast`); maturation triggers at `block.timestamp >= effectiveAt`. There is no overlap — a schedule cannot target "now," and the pending flips the instant its timestamp is reached. + +### Examples + +The `updateUIMultiplier(newMultiplier, effectiveAt)` function is the canonical path for corporate actions such as stock splits and reinvested dividends. Only one pending update can be live at a time. + +1. **Schedule**: Call `updateUIMultiplier(newMultiplier, effectiveAt)`. This requires `OPERATOR_ROLE`, and `effectiveAt` must be strictly in the future. +2. **Read the pending update**: While it is live, `newUIMultiplier()` returns the scheduled target, `effectiveAt()` returns the flip timestamp, and `uiMultiplier()` / `multiplier()` still return the current value. +3. **Let it mature**: Once `block.timestamp >= effectiveAt`, `uiMultiplier()` / `multiplier()` flip on read. No event fires at maturation. +4. **Or cancel it**: `cancelUIMultiplierUpdate()` clears a live pending update and emits `UIMultiplierUpdateCancelled(cancelledMultiplier, cancelledEffectiveAt)`. + +To reorder overlapping actions, cancel and reschedule atomically in one announcement: `announce([cancelUIMultiplierUpdate(), updateUIMultiplier(...)], ...)`. + +## Design Decisions & Alternatives Considered + +The instant setter (`updateMultiplier`) is retained as a deprecated dialable failsafe. It is the only on-chain recourse to correct or supersede a scheduled multiplier without waiting for `effectiveAt`. A cancel-then-schedule sequence cannot fix a bad scheduled value if the correction must apply immediately. Removing the instant setter would leave operators with no emergency override if a wrong `newMultiplier` or wrong `effectiveAt` were scheduled. It is gated by the pre-existing `OPERATOR_ROLE` (same as scheduling), not a narrower emergency-only role. + +A single pending slot (one live update at a time) is used instead of a queue. This choice was made for simplicity, gas efficiency, and single-slot storage packing. Reordering overlapping actions is handled by an atomic cancel-then-schedule in one announcement (see Examples). + +## Migration Steps + +Old functions work; there are no breaking changes. Migration steps are to update the workflow to use what is shown in the Examples section. + +Deprecation lifecycle (two tiers): + +- `updateMultiplier` is retained **indefinitely** as the emergency failsafe. It is not scheduled for removal — it is the only immediate on-chain override for a mis-scheduled value or timestamp. +- `toScaledBalance`, `toRawBalance`, and the legacy `MultiplierUpdated` event are deprecated-dialable for backward compatibility, with **no removal committed**. A future hardfork may remove them; none is scheduled. + +Off-chain integrators: Detect a live pending update via `effectiveAt() > block.timestamp`, never `== 0` (see Maturation and Materialization under Behavioural Changes). Prefer listening for `UIMultiplierUpdated` over the deprecated `MultiplierUpdated`. \ No newline at end of file diff --git a/changelog/02_Cobalt_B20_seize.md b/changelog/02_Cobalt_B20_seize.md index 752ae46..a32350a 100644 --- a/changelog/02_Cobalt_B20_seize.md +++ b/changelog/02_Cobalt_B20_seize.md @@ -1,150 +1,159 @@ -# B20: Beryl to Cobalt, seize surface and `burnBlocked` deprecation +# Seize surface + burnBlocked deprecation -> **Audience:** teams integrated against the base B20 surface on Beryl (live today) that perform -> administrative balance removal, today via the deprecated `burnBlocked`. This note covers only the -> seize surface landing at the Cobalt hardfork and what it means for `burnBlocked`. The surface is -> shared, so it applies to both B20 Asset and B20 Stablecoin. +- **Feature Name**: seize +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Seize surface + burnBlocked deprecation ## Summary -At Cobalt, the base B20 surface gains a first-class seize operation. `seizeWithMemo(from, to, -amount, memo)` reassigns a holder's balance to a destination in one admin call, gated by a new -`SEIZE_ROLE`, a new `SEIZE` pause vector, and two new policy slots (`SEIZE_HOLDER_POLICY`, -`SEIZE_RECEIVER_POLICY`). +Compliant asset issuers need freeze and seize models. This change adds `seizeWithMemo` to the shared `IB20` interface. Both B20 Asset and B20 Stablecoin inherit this function with no variant-specific logic. The original flow to seize an asset required three steps: block the account, call `burnBlocked`, then mint to reissue. The new `seizeWithMemo` function replaces this workaround with a single admin call that reassigns the balance directly. The `burnBlocked` function is deprecated but remains supported with unchanged behavior and no committed removal date. -Nothing you call today breaks: every Beryl selector, event topic, and error keeps its exact 4-byte -selector or topic0 and stays dialable at Cobalt. In particular, `burnBlocked` is deprecated but -unchanged (same selector, same events, same behavior) and remains callable. +## Motivation -To migrate, move administrative balance removal from `burnBlocked` to `seizeWithMemo`: seize to a -treasury or self address, then call `burn` if you want the supply destroyed. +Compliant asset issuers need freeze and seize models. Burn functionality must be explicitly distinct from seize because they may be gated on different policies. -Seize is opt-in per token. The surface exists at Cobalt, but seize does nothing until the issuer -configures `SEIZE_HOLDER_POLICY`. With the slot unset (always-allow), no account is seizable, and -every `seizeWithMemo` call reverts `AccountNotSeizable`. An issuer that never sets the policy has, in -effect, no seize capability on that token. +Today the workaround to achieve a seizure is: call `burnBlocked` to burn the asset (gated by `TRANSFER_SENDER_POLICY`, the same policy that `burnBlocked` reads), then call `mint` to reissue the same amount to the seize account. This approach has two problems. First, the emitted events (burn + mint) misrepresent the operation as a burn. No single event indicates that a seizure occurred. Second, `totalSupply` changes when the balance is burned, then changes again on reissue. -Cobalt hasn't gone live yet. Until it activates, only the Beryl surface exists on-chain, and every -`seize*`/`SEIZE_*` selector below is undialable. +The `seizeWithMemo` function replaces that workaround with a direct transfer to the seize account. It emits a dedicated `Seized` event. This makes seizing and burning explicitly distinct. -## Mapping table +## Background -The selectors and topic0s below are the real values from the frozen ABIs: -`crates/common/precompiles/src/common/abi/v1.rs` for Beryl, -`crates/common/precompiles/src/common/abi/v2.rs` for Cobalt. Every Beryl symbol keeps its selector -at Cobalt. Seize lives on the shared `IB20` surface, so it's identical across Asset and Stablecoin. +### B20 Asset and B20 Stablecoin -### Functions +B20 Asset and B20 Stablecoin are Base-native token contracts that extend ERC-20 with role-gated administrative functions and policy-gated operation checks. Each token stores fine-grained policy slots, keyed by operation and actor position. The token consults the Policy Registry through `isAuthorized(policyId, account)` to decide whether a given account is allowed for that slot. For example, transfer flows can independently gate the sender, receiver, and executor. Other operations use their own dedicated policy slots, such as mint receiver and seize holder/receiver. This design separates access control from compliance logic: roles determine who may call privileged methods, and policy slots determine which accounts may participate in a given token operation. -| Beryl symbol (selector) | Cobalt (selector) | Status | Why | -| --- | --- | --- | --- | -| `burnBlocked(address,uint256)` `0xec0cf3dc` | `burnBlocked(address,uint256)` `0xec0cf3dc` | deprecated-dialable | Kept unchanged for backward compatibility. Prefer `seizeWithMemo` then `burn`. Destroys supply and reads `TRANSFER_SENDER_POLICY`. | -| `BURN_BLOCKED_ROLE()` `0x32ad9be8` | `BURN_BLOCKED_ROLE()` `0x32ad9be8` | carried over unchanged | Still gates `burnBlocked` only. | -| — | `seizeWithMemo(address,address,uint256,bytes32)` `0xf916d81b` | new | Admin balance reassignment. A transfer, not a burn. | -| — | `SEIZE_ROLE()` `0x3c7e9ba5` | new | Required to call `seizeWithMemo`. Value `keccak256("SEIZE_ROLE")` = `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`. | -| — | `SEIZE_HOLDER_POLICY()` `0xb279d311` | new | Policy slot checked against `from`. Value `keccak256("SEIZE_HOLDER_POLICY")` = `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`. | -| — | `SEIZE_RECEIVER_POLICY()` `0xb31da27f` | new | Policy slot checked against `to`. Value `keccak256("SEIZE_RECEIVER_POLICY")` = `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`. | +### Policy Registry -### Events +The Policy Registry is a singleton precompile contract. Its responsibility is to return `isAuthorized(policyId, account)`. B20 uses the Policy Registry to gate operations by passing the stored policy ID and the account to check. -| Beryl event (topic0) | Cobalt (topic0) | Status | Why | -| --- | --- | --- | --- | -| `BurnedBlocked(address,address,uint256)` `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | unchanged | deprecated-still-emitted | Still emitted by `burnBlocked` alongside `Transfer(from, address(0), amount)`. | -| — | `Seized(address,address,address,uint256)` `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | new | Emitted by `seizeWithMemo` after `Transfer(from, to, amount)` and `Memo(caller, memo)`. | +## Specs -### Errors +### Interface Changes -| Beryl error (selector) | Cobalt (selector) | Status | Why | -| --- | --- | --- | --- | -| `AccountNotBlocked(address)` `0x64a5cb46` | unchanged | present on Beryl already | Thrown by `burnBlocked` when `from` is authorized under `TRANSFER_SENDER_POLICY` (that is, not blocked). | -| — | `AccountNotSeizable(address)` `0x91dbbc8d` | new | Thrown by `seizeWithMemo` when `from` is authorized under `SEIZE_HOLDER_POLICY` (that is, not seizable). | +The following changes add new functions, events, errors, and role/policy constants to the `IB20` interface. The deprecated `burnBlocked` function and its associated error and event remain but are marked deprecated. -### Pause features +**Deprecated (dialable, unchanged behavior, no removal date committed):** -`PausableFeature` is append-only. Cobalt adds one ordinal. +| Symbol | Selector / topic0 | +|--------|-------------------| +| `burnBlocked(address,uint256)` | `0xec0cf3dc` | +| `error AccountNotBlocked(address)` | `0x64a5cb46` | +| `event BurnedBlocked(address,address,uint256)` | `0x0b552e96653fd6842da37c477005d3b5c08a8c7d3631b1f43787b2dc9a1006a3` | -| Beryl ordinals | Cobalt addition | Storage bit | Why | -| --- | --- | --- | --- | -| `TRANSFER=0`, `MINT=1`, `BURN=2` | `SEIZE=3` | `1 << 3 = 8` | Independent pause vector for `seizeWithMemo`. `ALL_FEATURES_PAUSED` becomes `15` (`0b1111`). | +**New additions:** -`seizeWithMemo` is gated by the new `SEIZE` vector, not `BURN`. `burnBlocked` stays under `BURN`. +| Symbol | Selector / topic0 / value | +|--------|---------------------------| +| `seizeWithMemo(address,address,uint256,bytes32)` | `0xf916d81b` | +| `SEIZE_ROLE()` | `0x3c7e9ba5` (role value `0x3469b8b0d89e9604f8510ed143f74a8336d22955d4f83e23bf53d9414e27f432`) | +| `SEIZE_HOLDER_POLICY()` | `0xb279d311` (value `0x1497ab2b67ebb0a75dd9cdd6aec9f0e64620e6b87e911af7a088ac12e58d9ef2`) | +| `SEIZE_RECEIVER_POLICY()` | `0xb31da27f` (value `0xbf15b19caf5c77422c038bc25f26b8b815c3a14f6d04c6616076b81bcfe07b3d`) | +| `event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount)` | `0xa9aec5d8b86e2fa2fd6ac3af62f2622e3dfdab1967d4cbbb56a5df7d74cb887c` | +| `error AccountNotSeizable(address)` | `0x91dbbc8d` | +| `PausableFeature.SEIZE` | Enum value appended after `BURN` | -## New at Cobalt: adopt these +The net additions to the `IB20` interface surface are shown below. The `burnBlocked` function and its associated error and event remain but are marked deprecated. -### `seizeWithMemo(from, to, amount, memo)` +```solidity +// PausableFeature enum — SEIZE appended +enum PausableFeature { + TRANSFER, + MINT, + BURN, + SEIZE +} -This is the canonical administrative balance-removal path. It's a transfer: the balance moves from -`from` to `to`, and `totalSupply` is unchanged. It runs as an admin operation that skips allowance -and the transfer policies (`TRANSFER_SENDER/RECEIVER/EXECUTOR_POLICY`). It emits, in order: +error AccountNotSeizable(address account); -1. `Transfer(from, to, amount)` -2. `Memo(caller, memo)` (a memo of `bytes32(0)` is allowed) -3. `Seized(caller, from, to, amount)` +event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); -Requirements and guards: +function SEIZE_ROLE() external view returns (bytes32); -- **Role**: the caller must hold `SEIZE_ROLE`, or the call reverts `AccessControlUnauthorizedAccount`. -- **Pause**: `SEIZE` must not be paused, or the call reverts `ContractPaused(SEIZE)`. -- **Addresses**: `to != address(0)` and `from != to`, or the call reverts `InvalidReceiver`. - `from != address(0)`, or the call reverts `InvalidSender`. -- **Holder gate**: `from` must be blocked under `SEIZE_HOLDER_POLICY`, that is, not authorized by - it, or the call reverts `AccountNotSeizable`. An unset slot reads as always-allow, so no account - is seizable until an issuer configures `SEIZE_HOLDER_POLICY`. -- **Destination gate**: `to` must be authorized under `SEIZE_RECEIVER_POLICY`, which mirrors - `MINT_RECEIVER_POLICY` and is always enforced. But an unset slot is always-allow, so a token can - seize to any destination (a treasury doesn't need to be allowlisted) until the slot is set. -- **Balance**: `from`'s balance must be `>= amount`, or the call reverts `InsufficientBalance`. +function SEIZE_HOLDER_POLICY() external view returns (bytes32); +function SEIZE_RECEIVER_POLICY() external view returns (bytes32); -When multiple guards would fail, they take this precedence: holder gate, then destination gate, -then balance. That is, `AccountNotSeizable` fires before `PolicyForbids(SEIZE_RECEIVER_POLICY, -...)`, which fires before `InsufficientBalance`. +/// @notice Seizes `amount` of `from`'s balance and reassigns it to `to` in a single admin operation. +/// Emits `Transfer`, then `Memo`, then `Seized`. +function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; -## `burnBlocked` is deprecated, but unchanged and still dialable +// DEPRECATED — retained for back-compat +function burnBlocked(address from, uint256 amount) external; +``` -`burnBlocked(from, amount)` keeps working exactly as it does on Beryl: +**Policy semantics:** -- It destroys `amount` from a `from` blocked under `TRANSFER_SENDER_POLICY`, without spending an - allowance. It emits `Transfer(from, address(0), amount)` and `BurnedBlocked(caller, from, amount)` - (no `Memo`). -- It's gated by `BURN_BLOCKED_ROLE` and the `BURN` pause vector. -- It reverts `AccountNotBlocked` when `from` is authorized under `TRANSFER_SENDER_POLICY`. +- `SEIZE_HOLDER_POLICY` gates who is seizable. The membership is inverted: an account is seizable when it is **not** authorized under this policy. This mirrors the blocklist semantics of `burnBlocked`'s `TRANSFER_SENDER_POLICY` so the "blocked = seizable" model carries over. An unset slot reads as `0` (always-allow), so no account is seizable until an issuer configures the slot. This is a safe default. -To migrate, replace `burnBlocked(from, amount)` with `seizeWithMemo(from, treasury, amount, memo)`, -then call `burn(amount)` from the treasury if you still want the supply destroyed. This crosses two -policy, role, and pause domains (see the edge cases below), so it isn't a drop-in selector swap. +- `SEIZE_RECEIVER_POLICY` gates the seize destination. It mirrors `MINT_RECEIVER_POLICY`: always enforced on the seize destination. An unset slot defaults to always-allow, so an unconfigured token may seize to any destination (a treasury need not be allowlisted). -## Guarantees and edge cases +### Behavioural Changes -**Q: Does seize change `totalSupply`? Is it a burn?** -No. Seize is a transfer: it reassigns `amount` from `from` to `to` and leaves `totalSupply` -untouched. `burnBlocked` is the burn: it sends to `address(0)` and reduces supply. To reproduce the -old burn-blocked outcome, seize to a treasury or self address, then call `burn`. +**New function `seizeWithMemo(from, to, amount, memo)` execution flow:** -**Q: `seizeWithMemo` and `burnBlocked` both target "bad" accounts. Do they read the same set?** -No, and this is deliberate. `seizeWithMemo` reads `SEIZE_HOLDER_POLICY`. `burnBlocked` reads -`TRANSFER_SENDER_POLICY`. A token can define a seizable set that's distinct from its -transfer-blocked set. In both cases, "eligible" means not authorized by the relevant policy, and an -unset policy (always-allow) means nobody is eligible. +1. Check that `SEIZE` is not paused; else revert `ContractPaused(SEIZE)`. +2. Check that the caller holds `SEIZE_ROLE`; else revert `AccessControlUnauthorizedAccount`. +3. Reject zero or self destinations; else revert `InvalidReceiver`. +4. Reject zero source; else revert `InvalidSender`. +5. Require `from` to be not authorized under `SEIZE_HOLDER_POLICY`; else revert `AccountNotSeizable`. +6. Require `to` to be allowed by `SEIZE_RECEIVER_POLICY`; else revert `PolicyForbids(SEIZE_RECEIVER_POLICY, ...)`. +7. Check balance; else revert `InsufficientBalance`. +8. Emit `Transfer`, then `Memo`, then `Seized`. -**Q: Can I pause seize without pausing burns, or vice versa?** -Yes. `SEIZE` (ordinal 3) and `BURN` (ordinal 2) are independent pause bits. Pausing `BURN` doesn't -stop `seizeWithMemo`, and pausing `SEIZE` doesn't stop `burn`, `burnWithMemo`, or `burnBlocked`. +The `memo` parameter attaches an on-chain reference (for example, a case ID or legal order) to each seizure for compliance and audit trails. It is surfaced via the `Memo` event. -**Q: Do `SEIZE_ROLE` and `BURN_BLOCKED_ROLE` overlap?** -No. `seizeWithMemo` requires `SEIZE_ROLE`. `burnBlocked` requires `BURN_BLOCKED_ROLE`. Granting one -doesn't grant the other. +The `seizeWithMemo` function bypasses all three transfer-side policies (`TRANSFER_SENDER_POLICY`, `TRANSFER_RECEIVER_POLICY`, `TRANSFER_EXECUTOR_POLICY`). A seizure is a privileged admin action gated by `SEIZE_ROLE` and the seize policies, not a peer transfer. Therefore transfer-side compliance gating does not apply. -**Q: I never configured the seize policies. What happens if I call `seizeWithMemo`?** -It reverts `AccountNotSeizable(from)` for every `from`, because an unset `SEIZE_HOLDER_POLICY` is -always-allow, so no account is seizable. You must configure `SEIZE_HOLDER_POLICY` to designate -seizable holders before seize does anything. (Leaving `SEIZE_RECEIVER_POLICY` unset simply permits -any destination.) +A dedicated pause vector `PausableFeature.SEIZE` pauses `seizeWithMemo`. When `SEIZE` is paused, the function reverts with `ContractPaused(SEIZE)`. -**Q: Does seize consult the transfer policies or spend an allowance?** -No. It's an admin operation: it bypasses `TRANSFER_SENDER/RECEIVER/EXECUTOR_POLICY` and allowances, -and enforces only `SEIZE_HOLDER_POLICY` (on `from`) and `SEIZE_RECEIVER_POLICY` (on `to`). +Seize is a transfer, not a burn. The balance moves from `from` to `to` and `totalSupply` remains unchanged. This is the key behavioral difference from `burnBlocked`, which sends to `address(0)` and reduces supply. -**Q: Is seize available on B20 Stablecoin as well as B20 Asset?** -Yes. It's defined on the shared `IB20` surface, so both variants expose the identical -`seizeWithMemo` selector, `Seized` topic0, `AccountNotSeizable` selector, `SEIZE_*` getters, and -`SEIZE` pause bit at Cobalt. +**Storage layout change:** A packed `seizePolicyIds` slot is added for `SEIZE_HOLDER_POLICY` and `SEIZE_RECEIVER_POLICY`. This change is additive; `burnBlocked` storage remains unchanged. + +### Examples + +**Before (old block + burn + mint workaround, still available, deprecated):** + +1. Configure `from` as blocked under `TRANSFER_SENDER_POLICY`. +2. Call `burnBlocked(from, amount)` — burns `from`'s balance, gated by `BURN_BLOCKED_ROLE`. +3. Call `mint(treasury, amount)` — separately reissues the same amount, gated by `MINT_ROLE`. +4. Emits: `Transfer(from, address(0), amount)` + `BurnedBlocked(caller, from, amount)` + `Transfer(address(0), treasury, amount)` — two independent operations. + +**After (new, single call):** + +1. Configure `from` as NOT authorized under `SEIZE_HOLDER_POLICY` (i.e., blocked). +2. Call `seizeWithMemo(from, treasury, amount, memo)` — gated by `SEIZE_ROLE`. +3. Emits, in order: + - `Transfer(from, treasury, amount)` + - `Memo(caller, memo)` + - `Seized(caller, from, treasury, amount)` + +## Design Decisions & Alternatives Considered + +**Final shipped shape:** `seizeWithMemo` and `burnBlocked` use fully independent policy slots and pause vectors. + +- `seizeWithMemo` uses the new `SEIZE_HOLDER_POLICY` (for `from`) and `SEIZE_RECEIVER_POLICY` (for `to`), the new `SEIZE_ROLE`, and the new `PausableFeature.SEIZE`. +- `burnBlocked` retains `TRANSFER_SENDER_POLICY`, `BURN_BLOCKED_ROLE`, and the `BURN` pause vector unchanged. +- Seize operations are rare, so the reserved lane in the transfer packed policy slot was not reused for seize. That lane is kept open for a possible future transfer-side optimization where another hot-path transfer policy could be packed into the existing transfer slot without adding a second `SLOAD`. Because seize is a cold-path/rare-path operation, it instead gets its own packed `seizePolicyIds` slot. + +### Function Naming Alternatives + +A shared seize-policy approach was rejected: burning and seizing have different effects (see Behavioural Changes), so `burnBlocked` remains independent and no `burnBlockedWithMemo` variant is included. + +The name `transferFromBlockedWithMemo` was considered and rejected. `seizeWithMemo` names the intent (seizure) rather than the mechanism (blocked transfer). + +## Migration Steps + +**Backwards-compatible:** `burnBlocked` continues to work unchanged. No action is required if you do not need seize behavior yet. + +**No breaking changes:** All existing selectors, events, and errors remain dialable. + +**To adopt `seizeWithMemo`:** + +1. Grant `SEIZE_ROLE` to the account(s) that should be able to seize. With no `SEIZE_ROLE` holders, no one can seize. +2. Configure `SEIZE_HOLDER_POLICY` so the accounts you want seizable are NOT authorized under it. With no policy configured (unset = always-allow), no account is seizable. +3. Optionally configure `SEIZE_RECEIVER_POLICY` to restrict where seized funds may land. Unset defaults to always-allow (for example, an unallowlisted treasury still works). + +**To reproduce `burnBlocked`'s destroy-supply outcome with seize:** `seizeWithMemo` alone does not reduce `totalSupply`. Seize to a treasury or self address, then call `burn(amount)` from that address if you want the supply destroyed. + +**No storage migration:** `burnBlocked`'s storage and behavior are untouched by this change. \ No newline at end of file diff --git a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md index 60366a8..bc427b8 100644 --- a/changelog/02_Cobalt_PolicyRegistry_composite_policy.md +++ b/changelog/02_Cobalt_PolicyRegistry_composite_policy.md @@ -1,143 +1,222 @@ -# PolicyRegistry: composite policies (UNION / INTERSECT) +# Composite Policies (UNION/INTERSECT) -> **Audience:** teams integrated against `PolicyRegistry` on Beryl, creating and administering -> simple `ALLOWLIST`/`BLOCKLIST` policies, and referencing policy IDs on B20 policy slots. This note -> covers only the composite (`UNION`/`INTERSECT`) policy support landing at the Cobalt hardfork. +- **Feature Name**: composite_policy +- **Start Date**: 2026-08-17 +- **Authors**: Rayyan Alam +- **Title**: Composite Policies (UNION/INTERSECT) ## Summary -At Cobalt, `PolicyRegistry` gains composite policies: a policy that authorizes by combining 2–4 -existing simple policies under a `UNION` (OR) or `INTERSECT` (AND) gate. Create one with the new -`createCompositePolicy`, and mutate it in full with the new `updateComposite`. +This feature introduces two new `PolicyRegistry` policy types: `UNION` (OR) and `INTERSECT` (AND), collectively called composite policies. A composite policy authorizes by combining the results of two to four existing simple policies (`ALLOWLIST` or `BLOCKLIST`). The children of composite policies are only existing simple policies; this constraint is enforced at write time. The feature enables policy reuse by allowing a single composite policy to reference multiple simple policies. Updating one child policy automatically updates every composite that references it. -Nothing you call today breaks: every Beryl selector, event topic, and error keeps its exact 4-byte -selector or topic0 and stays dialable at Cobalt. The only change to existing behavior is that -`createPolicy` and `createPolicyWithAccounts` gain one new, previously unreachable, revert path, -rejecting a composite `policyType` with the already-existing `IncompatiblePolicyType` error. +## Motivation -Until Cobalt activates, only the Beryl (simple-policy) surface exists on-chain, and every composite -selector below is undialable. +The policy registry currently supports simple boolean policies through `isAuthorized`, where each policy independently returns true or false. In practice, access control often requires combining multiple policies. For example, an application might require both KYC verification and ProUser status, or either ProUser status or LifetimeUser status. The current architecture requires a user to listen to changes on a different allowlist and flatten into one, which duplicates lists and requires infrastructure to keep them up to date. This feature allows policy reuse by creating composite policies that combine the results of other policies, which simplifies maintenance because updating one child policy updates every composite that references it. -## Mapping table +## Background -The selectors and topic0s below are computed directly from `src/interfaces/IPolicyRegistry.sol` -with `cast sig` and `cast sig-event`. Every Beryl symbol keeps its selector at Cobalt. +### B20 Token -### `PolicyType` enum +B20 is a token precompile that uses policies to restrict operations such as transfers, minting, and seizing. For each restricted operation, B20 stores a Policy Registry policy ID in a dedicated policy scope. When an operation is attempted, B20 passes the relevant policy ID and account address to the Policy Registry. If the account is not authorized, B20 rejects the operation. -| Beryl | Cobalt addition | Why | -| --- | --- | --- | -| `BLOCKLIST = 0`, `ALLOWLIST = 1` | `UNION = 2`, `INTERSECT = 3` | Append-only. Existing values and the packed-ID top-byte encoding are unchanged. | +### Policy Registry -### Functions +The Policy Registry is a singleton precompile contract used by B20 tokens. It manages a list of policies; B20 tokens call `isAuthorized(policyId, account)` against a policy ID stored on the relevant policy scope. Currently, B20 tokens use the Policy Registry for `TRANSFER_FROM`, `TRANSFER_TO`, and `SEIZE_HOLDER`. -| Beryl symbol (selector) | Cobalt (selector) | Status | Why | -| --- | --- | --- | --- | -| `createPolicy(address,uint8)` `0xca5d55f6` | unchanged | present on Beryl already, new revert path | Now also reverts `IncompatiblePolicyType` when `policyType` is `UNION`/`INTERSECT`, checked after `ZeroAddress`. | -| `createPolicyWithAccounts(address,uint8,address[])` `0xa2d3044f` | unchanged | present on Beryl already, new revert path | Same composite-type rejection, checked after `ZeroAddress` and before `BatchSizeTooLarge`. | -| — | `createCompositePolicy(address,uint8,uint64[])` `0x6fdd1491` | new | Creates a `UNION`/`INTERSECT` policy from 2–4 existing simple policy IDs. | -| — | `updateComposite(uint64,uint64[])` `0xbfe142c0` | new | Replaces a composite's child-policy set in full. There's no partial-update or clear-the-list path. | -| — | `compositePolicyChildIds(uint64)` `0x7c40df74` | new | Read-only child-set getter. Always callable, not gated. | -| — | `MIN_COMPOSITE_CHILD_POLICIES()` `0xb3ae29f7` | new | Constant `2`. Always callable. | -| — | `MAX_COMPOSITE_CHILD_POLICIES()` `0x54309870` | new | Constant `4`. Always callable. | - -`isAuthorized(uint64,address)` `0x55a1179e`, `policyExists(uint64)` `0x330f5637`, -`policyAdmin(uint64)` `0x09dd0a47`, `pendingPolicyAdmin(uint64)` `0x017548b7`, -`updateAllowlist(uint64,bool,address[])` `0x3388fb5b`, `updateBlocklist(uint64,bool,address[])` -`0x5c4e51b8`, `stageUpdateAdmin(uint64,address)` `0x1d7ae695`, `finalizeUpdateAdmin(uint64)` -`0x33031a9c`, and `renounceAdmin(uint64)` `0xefdb7fa3` carry over unchanged. - -### Events - -| Beryl event (topic0) | Cobalt (topic0) | Status | Why | -| --- | --- | --- | --- | -| `PolicyCreated(uint64,address,uint8)` `0x718d87917f0c4cfd1263707ef0e77c656ed8d8bfaca06152bdb0b8094142ec27` | unchanged | carried over | Also emitted for composite creation, with `policyType` `UNION`/`INTERSECT`. | -| — | `CompositePolicyUpdated(uint64,address,uint64[])` `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | new | Emitted on composite creation and every `updateComposite` call. Carries the complete post-update child set. | - -`PolicyAdminStaged`, `PolicyAdminUpdated`, `AllowlistUpdated`, and `BlocklistUpdated` carry over -unchanged. They aren't emitted for composites, because composites have no membership set of their -own. - -### Errors - -| Beryl error (selector) | Cobalt (selector) | Status | Why | -| --- | --- | --- | --- | -| `IncompatiblePolicyType()` `0xf1011ef5` | unchanged | present on Beryl already, new call sites | Now also thrown by `createPolicy`/`createPolicyWithAccounts` (a composite `policyType` passed to a simple constructor), `createCompositePolicy` (`policyType` isn't `UNION`/`INTERSECT`), and `updateComposite` (the target isn't a composite). | -| `PolicyNotFound()` `0x720caa4f` | unchanged | present on Beryl already, new call sites | Now also thrown for the composite target itself in `updateComposite`, and for any nonexistent child in `createCompositePolicy`/`updateComposite`. | -| — | `ChildPoliciesOutsideOfRange()` `0x697ec868` | new | Thrown when the child count is outside `[MIN_COMPOSITE_CHILD_POLICIES, MAX_COMPOSITE_CHILD_POLICIES]` (`[2, 4]`). | -| — | `InvalidChildPolicy(uint64)` `0x46508ef6` | new | Thrown when a child is itself a composite, or a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`). | - -## New at Cobalt: adopt these - -### `createCompositePolicy(admin, policyType, childPolicyIds)` - -Creates a `UNION`/`INTERSECT` policy over 2–4 existing simple policy IDs. Each check fires before -the next, in this order: - -1. `ZeroAddress`: `admin == address(0)`. -2. `IncompatiblePolicyType`: `policyType` isn't `UNION`/`INTERSECT`. -3. `ChildPoliciesOutsideOfRange`: `childPolicyIds.length` is outside `[2, 4]`. -4. `PolicyNotFound`: any child doesn't exist. This is checked as one pass over the whole set, before - the next check. -5. `InvalidChildPolicy`: any child is a composite or a built-in sentinel. This is a second pass. - -On success, it emits `PolicyCreated(policyId, creator, policyType)`, -`PolicyAdminUpdated(policyId, 0, admin)`, then `CompositePolicyUpdated(policyId, creator, -childPolicyIds)`. - -### `updateComposite(policyId, childPolicyIds)` - -Replaces a composite's child-policy set in full. A child omitted from the new set no longer governs -the composite; there's no partial-update or clear-the-list path. Checks run in this order: - -1. `PolicyNotFound`: `policyId` doesn't exist. -2. `IncompatiblePolicyType`: `policyId` is a simple policy, not a composite. -3. `Unauthorized`: the caller isn't the current admin. A renounced composite (admin `address(0)`) - can never be updated. -4. `ChildPoliciesOutsideOfRange`: the new count is outside `[2, 4]`. -5. `PolicyNotFound`: any new child doesn't exist. -6. `InvalidChildPolicy`: any new child is a composite or a built-in sentinel. - -It emits `CompositePolicyUpdated(policyId, updater, childPolicyIds)`. - -### Live, depth-1 evaluation - -`isAuthorized` on a composite calls each child policy's `isAuthorized`; it never uses a snapshot -taken at creation or the last update. `UNION` returns `true` on the first authorizing child (it -short-circuits). `INTERSECT` returns `false` on the first non-authorizing child. Recursion never -exceeds depth 1, because every child is validated to be a simple (`ALLOWLIST`/`BLOCKLIST`) policy at -write time, so a composite's children can never themselves be composites. - -## Guarantees and edge cases - -**Q: Can a composite's child be another composite (nested composites)?** -No. `createCompositePolicy` and `updateComposite` revert `InvalidChildPolicy(childPolicyId)` for any -child whose type is `UNION`/`INTERSECT`. Nesting is impossible by construction. - -**Q: Can a built-in sentinel (`ALWAYS_ALLOW`/`ALWAYS_BLOCK`) be a composite child?** -No, for the same `InvalidChildPolicy` revert. To mix always-allow or always-block behavior into a -composite gate, use a real `ALLOWLIST`/`BLOCKLIST` policy that reproduces the effect you want -instead. - -**Q: Can I pass the same child ID twice, or shrink a composite below 2 children?** -Duplicates are allowed. The registry neither sorts nor deduplicates the stored child list; the only -cost is extra evaluation, since `UNION`/`INTERSECT` are idempotent under duplicates. Shrinking below -2 isn't possible: every `updateComposite` call enforces the same `[2, 4]` range as creation, so -there's no path to an empty or undersized composite. - -**Q: If a child policy's admin renounces, does the parent composite break?** -No. `renounceAdmin` on the child only clears its admin and freezes its future membership changes. -The child still exists, and `isAuthorized` on it still resolves normally, so the composite keeps -evaluating it exactly as before. - -**Q: Is composite mutation gated separately from simple-policy mutation?** -No. `createCompositePolicy` and `updateComposite` are gated by the same `ActivationRegistry` flag -that gates `createPolicy`, `updateAllowlist`, and others. There's no composite-specific activation -flag. `compositePolicyChildIds`, `MIN_COMPOSITE_CHILD_POLICIES`, `MAX_COMPOSITE_CHILD_POLICIES`, and -`isAuthorized` on a composite ID are all always callable, whether or not the feature is active. - -**Q: Can a B20 token's policy slot (for example, `TRANSFER_SENDER_POLICY` or `SEIZE_HOLDER_POLICY`) -reference a composite ID?** -Yes. B20 stores every policy slot as an opaque `uint64 policyId` and calls `isAuthorized`, so a -composite ID works exactly like a simple one, and no B20-side change was needed. As with any policy -ID, validate `policyExists(policyId)` before writing it to a slot. +### Simple Policies + +Simple policies are the non-composite policy types: `ALLOWLIST` and `BLOCKLIST`. + +- `ALLOWLIST` has a list of addresses. It returns authorized `true` if the address is in the list, `false` otherwise. +- `BLOCKLIST` has a list of addresses. It returns authorized `false` if the address is in the list, `true` for all other addresses. + +## Specs + +### Interface Changes + +The following interface changes are verified via `cast sig` and `cast keccak` against `src/interfaces/IPolicyRegistry.sol`. + +| Symbol | Selector / Topic0 | Status | Notes | +|--------|-------------------|--------|-------| +| `createCompositePolicy(address,uint8,uint64[])` | `0x6fdd1491` | NEW | `PolicyType` ABI-encodes as `uint8`; creates a UNION/INTERSECT composite | +| `updateComposite(uint64,uint64[])` | `0xbfe142c0` | NEW | Full replacement of the child set | +| `compositePolicyChildIds(uint64)` | `0x7c40df74` | NEW (view) | Returns the stored child set verbatim; empty for non-composites | +| `MIN_COMPOSITE_CHILD_POLICIES()` | `0xb3ae29f7` | NEW (view) | Returns `2` | +| `MAX_COMPOSITE_CHILD_POLICIES()` | `0x54309870` | NEW (view) | Returns `4` | +| `ChildPoliciesOutsideOfRange()` | `0x697ec868` | NEW (error) | Child count not in `[2, 4]`; distinct from `BatchSizeTooLarge` (64-account cap) | +| `InvalidChildPolicy(uint64)` | `0x46508ef6` | NEW (error) | Child is itself a composite or a built-in sentinel | +| `CompositePolicyUpdated(uint64,address,uint64[])` | `0x4ff6adaab31b0df87aa7b8b7320c52b8b3b5eede3bf28a6baaaa8b8b7e1d6363` | NEW (event) | Topic0; emitted on composite create and every update; carries full post-update set | +| `isAuthorized(uint64,address)` | (unchanged) | extended | Now dispatches composites (live child evaluation); signature unchanged | +| `createPolicy(address,uint8)` | `0xca5d55f6` | extended | Now rejects `UNION`/`INTERSECT` with `IncompatiblePolicyType` (see below) | +| `createPolicyWithAccounts(address,uint8,address[])` | `0xa2d3044f` | extended | Same new `IncompatiblePolicyType` rejection | + +The `PolicyType` enum introduces two new values: +- `UNION = 2` — authorized if any child policy authorizes the account (OR) +- `INTERSECT = 3` — authorized only if every child policy authorizes the account (AND) + +#### `createCompositePolicy(admin, policyType, childPolicyIds)` + +The `childPolicyIds` array must contain between 2 and 4 entries (enforced by `MIN_COMPOSITE_CHILD_POLICIES` and `MAX_COMPOSITE_CHILD_POLICIES`). The cap of 4 bounds worst-case `isAuthorized` gas and the authorization audit surface. Every child must be an existing simple policy (`ALLOWLIST` or `BLOCKLIST`) — never another composite, never a built-in sentinel (`ALWAYS_ALLOW` or `ALWAYS_BLOCK`). + +The canonical revert order is: +1. `ZeroAddress` (admin) +2. `IncompatiblePolicyType` (policyType not UNION/INTERSECT) +3. `ChildPoliciesOutsideOfRange` (count not in `[2, 4]`) +4. `PolicyNotFound` (a child doesn't exist, checked as one pass over the whole set) +5. `InvalidChildPolicy` (a child is itself composite or sentinel, checked as a second pass) + +The function emits, in order: +- `PolicyCreated(policyId, creator, policyType)` +- `PolicyAdminUpdated(policyId, address(0), admin)` +- `CompositePolicyUpdated(policyId, creator, childPolicyIds)` + +#### `updateComposite(policyId, childPolicyIds)` + +This function performs a full replacement of the child set. There is no partial-update or clear-the-list operation. The same child-validity rules as `createCompositePolicy` apply: existing simple policies only, 2 to 4 of them. + +The canonical revert order is: +1. `PolicyNotFound` (composite itself doesn't exist) +2. `IncompatiblePolicyType` (`policyId` is a simple policy) +3. `Unauthorized` (caller isn't the current admin — fires before the count check) +4. `ChildPoliciesOutsideOfRange` +5. `PolicyNotFound` (a new child doesn't exist) +6. `InvalidChildPolicy` + +The function emits only `CompositePolicyUpdated(policyId, updater, childPolicyIds)` — no `PolicyAdminUpdated`, since the admin does not change. + +#### Existing Functions with Changed Revert Behavior + +`createPolicy` and `createPolicyWithAccounts` (both already live on Beryl) are simple-policy constructors that now reject `UNION`/`INTERSECT` with `IncompatiblePolicyType`. This is not merely a newly-reachable branch — the revert for the same calldata changes across the fork. Pre-Cobalt, the `PolicyType` enum had only `BLOCKLIST`/`ALLOWLIST`, so calldata carrying type byte `2`/`3` failed ABI enum decode (Solidity reference: `Panic(0x21)`, enum-conversion out of range). Post-Cobalt, byte `2`/`3` decodes cleanly as `UNION`/`INTERSECT`, then the explicit guard reverts `IncompatiblePolicyType`. + +**UNVERIFIED**: The exact pre-Cobalt revert of the Rust precompile for an out-of-range `PolicyType` byte is not asserted here. The Solidity mock does not model ABI enum decode. Confirm via `base-forge test` before publishing, or document only as Solidity-reference behavior. + +### Behavioural Changes + +A composite policy ID is passed to a B20 policy slot exactly like a simple policy ID. B20 needs zero code changes because it stores policy slots as an opaque `uint64` and calls `isAuthorized` generically. + +`isAuthorized` on a composite is live and short-circuiting, not a snapshot: +- It reads each child's current membership on every call — no snapshot from creation or the last `updateComposite`. +- `UNION` short-circuits `true` on the first authorizing child. +- `INTERSECT` short-circuits `false` on the first non-authorizing child. +- Recursion never exceeds depth 1 because every child is validated to be a simple policy at write time. A composite's children can never themselves be composites. + +`isAuthorized` on a well-formed but never-created composite ID collapses to empty-child-set semantics: `UNION` returns `false` (deny-all), `INTERSECT` returns `true` (allow-all — an AND over zero children is vacuously true). This parallels the simple-policy empty-set rule (`ALLOWLIST` → `false`, `BLOCKLIST` → `true`). Consumers that store a composite ID (for example, on a B20 policy slot) MUST validate `policyExists(policyId)` at write time. A typo'd INTERSECT ID would silently behave as `ALWAYS_ALLOW`. + +Gas: a composite reads more policy IDs than a simple policy (its child list, plus each evaluated child's membership), so `isAuthorized` on a composite costs more gas than on a simple policy. + +Child order affects gas, never the outcome: +- `UNION`/`INTERSECT` are commutative, so reordering `childPolicyIds` never changes whether an account is authorized. +- It only shifts where the short-circuit lands. Put the child most likely to short-circuit first (broadest ALLOWLIST for `UNION`, tightest BLOCKLIST for `INTERSECT`) to save gas. + +Duplicate child IDs are allowed. The registry neither sorts nor deduplicates the stored child list. Deduplicating would cost extra gas on every write for a set already capped at 4 entries, for little value. `UNION`/`INTERSECT` are idempotent under duplicates anyway. + +A composite can never shrink below 2 children via `updateComposite` — it enforces the same `[2, 4]` range as creation, so there is no path to an empty or undersized composite. + +If a child policy's admin renounces, the parent composite keeps working. `renounceAdmin` only clears the child's admin and freezes its future membership changes. The child still exists and `isAuthorized` on it still resolves normally, so the composite keeps evaluating it exactly as before. + +#### State Changes + +- New state: `mapping(uint64 policyId => uint64[] childPolicyIds) children`, appended at offset 4 within the `base.policy_registry` ERC-7201 namespace (not a literal EVM slot 4). This is appended so existing state at offsets 0–3 is unmodified and no storage migration is needed. +- Reused state: one shared global counter (`nextCounter`) across simple and composite policies, starting at 2 (`0` and `1` are reserved for `ALWAYS_ALLOW`/`ALWAYS_BLOCK`). A composite policy ID encodes `PolicyType` in the top byte and the next available counter value in the low 56 bits — the same encoding scheme as simple policies, not a separate counter. + +### Examples + +#### Before (Simple Policy) + +Assign one existing policy directly to a B20 policy scope: + +```solidity +b20.updatePolicy(TRANSFER_SENDER_POLICY, allowlistPolicyId) +``` + +Only accounts in `allowlistPolicyId` can transfer. + +#### After (Composite Policy) + +Assume existing simple policies: `employeesPolicyId` (ALLOWLIST), `approvedRegionPolicyId` (ALLOWLIST). + +Create a UNION composite: + +```solidity +policyRegistry.createCompositePolicy(admin, UNION, [employeesPolicyId, approvedRegionPolicyId]) +``` + +Emits: `PolicyCreated(policyId, admin, UNION)` + `PolicyAdminUpdated(policyId, 0, admin)` + `CompositePolicyUpdated(policyId, admin, [children])`. + +Assign to B20: + +```solidity +b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId) +``` + +B20 has no composite-specific logic — it passes the policy ID to the registry as usual. + +#### Updating a Composite + +```solidity +policyRegistry.updateComposite(compositePolicyId, [employeesPolicyId, trustedPartnersPolicyId]) +``` + +Emits: `CompositePolicyUpdated(policyId, admin, [newChildren])`. + +B20 continues using the same policy ID — no token-side update required. + +Future authorization checks use the new child set immediately (live evaluation, no snapshot). + +## Design Decisions & Alternatives Considered + +**Decision**: Two explicit policy types (`UNION`, `INTERSECT`) with a single `createCompositePolicy` function and full-replacement `updateComposite`. + +**Alternative 1: One generic COMPOSITE type** +- Store a separate operator (AND, OR, NOT, XOR) in composite storage. +- Rejected because: + - Requires storing both "composite" flag and the operator. + - Adds storage reads or more complicated ID encoding. + - Unnecessary complexity before there is a requirement for NOT, XOR, or nested expressions. + - Generic boolean expressions create a larger gas and audit surface. + +**Alternative 2: Token-level policy groups** +- Keep Policy Registry unchanged; have each B20 token store multiple policy IDs + an operator. +- Rejected because: + - Composite policies would not be reusable entities. + - Requires changes across B20, token variants, factories, and token hot paths. + - Does not support sharing one composite policy across multiple tokens. + - Spreads complexity across more contracts. + +**Alternative 3: Incremental child updates** +- Provide `addCompositeOperand` / `removeCompositeOperand` functions. +- Rejected because: + - Child list is capped at 4 entries. + - Dynamic-array mutation requires swap/remove, length, and deduplication logic. + - Full replacement is simpler and atomic. + - Caller can resend the complete list at low cost. + +**Alternative 4: Separate creator functions** +- Use `createUnionPolicy` and `createIntersectPolicy`. +- Rejected because: + - Doubles the creation API surface. + - A single `createCompositePolicy` keeps policy creation consistent. + - Future operators would require additional functions. + +**Alternative 5: Nested composites (a composite referencing another composite)** +- Allow composite children, to some bounded depth, instead of restricting children to simple `ALLOWLIST`/`BLOCKLIST` policies. +- Rejected because: + - Restricting children to simple policies guarantees `isAuthorized` recursion terminates at depth 1 — no cycle risk, no unbounded traversal. + - Bounds worst-case gas and the audit surface of authorization evaluation. + - No demonstrated need for nested expressions; a wrapper composite can be introduced later if one ever arises. + +## Migration Steps + +- **Backwards-compatible**: Existing simple policies (`ALLOWLIST`/`BLOCKLIST`) continue to work unchanged. No action required if you do not need composite behavior. + +- **For users currently flattening multiple lists into one policy**: + 1. Identify the simple policies you want to combine. + 2. Call `policyRegistry.createCompositePolicy(admin, UNION or INTERSECT, [childPolicyIds])`. + 3. Update the B20 token's policy scope to point to the new composite policy ID: + - `b20.updatePolicy(TRANSFER_SENDER_POLICY, compositePolicyId)` + - No B20 contract change is required — B20 treats the composite ID as an opaque `uint64` exactly like a simple policy ID. + 4. Remove the old flattened policy if no longer needed. + +- **No breaking changes**: All existing selectors, events, and errors remain dialable at Cobalt. + +- **No storage migration**: `children` is a new, empty mapping at ERC-7201 offset 4. Existing `PolicyRegistry` state at offsets 0–3 is unmodified by Cobalt activation. \ No newline at end of file diff --git a/changelog/AGENTS.md b/changelog/AGENTS.md index 2d62f06..d483a7c 100644 --- a/changelog/AGENTS.md +++ b/changelog/AGENTS.md @@ -28,26 +28,6 @@ Each file name has four parts: Never rename or renumber a shipped entry. Once a hardfork activates on-chain, its entries are frozen; only add new files for new hardforks or features. -## Writing an entry - -Keep entries minimal and migration-focused. Don't restate unchanged behavior. A good entry has: - -1. An audience statement and a one-paragraph summary that leads with the compatibility promise: - what still works, what's deprecated but still dialable, and what's new. State plainly whether - the fork is live yet. -2. A mapping table: old symbol, new symbol, status (`deprecated-dialable`, `renamed`, or `new`), and - a one-line reason. Cover functions, events, and errors, with real signatures and selectors. -3. A "New at `<hardfork>` (adopt these)" section describing the new surface and its lifecycle. -4. A guarantees and edge cases section: a short Q&A covering what a careful integrator would ask, - each answered with the precise on-chain behavior. - -Before you cite a function signature, event signature or topic0, error selector, 4-byte function -selector, or ERC-165 interface ID, verify it against the source (`src/interfaces/**` and the -base/base ABIs). Don't rely on memory, and don't trust a prior entry's numbers even for a symbol -you believe is unchanged. Check selectors with `cast sig` or `cast keccak`. - -If you can't verify a value, flag it in the entry or ask, rather than shipping a plausible-looking -but unverified selector. ## Indexing a new entry diff --git a/changelog/TEMPLATE_POINT_FORM.md b/changelog/TEMPLATE_POINT_FORM.md new file mode 100644 index 0000000..812b126 --- /dev/null +++ b/changelog/TEMPLATE_POINT_FORM.md @@ -0,0 +1,64 @@ +# ADR Template (Point Form) + +- **Feature Name**: my_awesome_feature +- **Start Date**: 2026-08-17 +- **Authors**: +- **Title**: + +> Fill in each section below when documenting a change. Pointform here can be sufficient + +--- + +## Summary + +> Give a summary of the change: who it's for, what it does. + +--- + +## Motivation + +> State the problem this change solves, why the current state is insufficient, and brief context +> on prerequisites if needed (full detail lives in Background). + +--- + +## Background + +> Link to or summarize concepts the reader needs before understanding the Specs: prior art, +> relevant EIPs/ERCs, existing patterns, and any domain-specific terms used in this document. + +--- + +## Specs + +> Implementation details live here. Add more headings as needed (e.g., Storage Layout Changes, +> Deprecated Assets, Access Control, etc.). + +### Interface Changes + +> New functions, events, errors (include signatures). Renamed or deprecated symbols (old → new). +> Selector / topic0 values. + +### Behavioural Changes + +> How execution flow differs from the previous version. Storage layout changes (new slots, moved +> fields, packing changes). Gas cost implications if meaningful. + +### Examples + +> Before/after code snippets or call sequences. Expected return values or emitted events. + +--- + +## Design Decisions & Alternatives Considered + +> Describe the approach taken and why. Document alternatives considered and why they were rejected. +> Note any opinionated choices and their rationale. + +--- + +## Migration Steps + +> Steps for integrators to adopt the new interface. Call out what is backwards-compatible, any +> deprecation timeline, and breaking changes that require action before activation. +