// SPDX-License-Identifier: MIT pragma solidity 0.8.28; /// @title VersionRegistry — which manifest is the current one. /// /// # What this is NOT /// /// It is not permission. Nothing here decides what an account may install: /// `AgentRegistry` does, behind a three-day delay, and `AdapterInstaller` asks /// it rather than the caller. A hostile manifest published here still installs /// nothing, because the addresses it names are not in the registry. /// /// # The hole it does close /// /// A DOWNGRADE, and it is worth being precise because the registry cannot see /// it. Superseded adapters stay whitelisted -- delisting is a separate act /// somebody has to remember -- so an OLD manifest is a genuine one: every /// address in it is still permitted, and every check against `adapters()` /// passes. Serve yesterday's file and a console offers yesterday's adapter as /// current, with nothing anywhere disagreeing. /// /// That is what the monotonic version stops. A manifest is current only if it is /// the one this contract names, and this contract will never name an older one /// again. /// /// # What a console does with it /// /// Fetch the manifest, hash the exact bytes, compare to `currentHash()`. Equal /// means the file is the published one. Not equal means stale or swapped, and /// either way it must not be shown as an update -- somebody seeing an /// official-looking "update available" is most of the way to signing something. /// /// The URI is a HINT and not an authority. Whoever can change where a file is /// served can change the file; the hash is what makes that not matter. contract VersionRegistry { struct Release { /// keccak256 of the manifest's exact bytes. The whole point. bytes32 manifestHash; /// Monotonic. See the note above on downgrades. uint64 version; uint64 publishedAt; /// Where to fetch it. Convenience only. string uri; } Release private _current; /// The key that may publish. Deliberately NOT the guardian. /// /// The guardian decides what may be installed and is bounded by a delay for /// exactly that reason. Publishing a manifest grants nothing, so it needs no /// delay -- but it should not therefore ride on the key that does grant /// things. One key doing both means a compromise of the cheap capability is /// a compromise of the expensive one. address public publisher; event Published(uint64 indexed version, bytes32 indexed manifestHash, string uri); event PublisherTransferred(address indexed from, address indexed to); error NotPublisher(); error EmptyHash(); error NotNewer(uint64 current, uint64 given); /// A version so far ahead that it would burn the remaining range. There is /// no reset here, so a jump nobody meant is permanent. error JumpTooLarge(uint64 current, uint64 given, uint64 max); error ZeroPublisher(); constructor(address publisher_) { if (publisher_ == address(0)) revert ZeroPublisher(); publisher = publisher_; emit PublisherTransferred(address(0), publisher_); } modifier onlyPublisher() { if (msg.sender != publisher) revert NotPublisher(); _; } /// The furthest ahead a single publish may jump. /// /// M-14 OF THE 2026-09-10 AUDIT. The monotonicity check had a floor and no /// CEILING, and there is no reset, no owner and no proxy here — so one /// publish at `type(uint64).max` ended publishing forever and pinned /// `currentHash()` to whatever that call named. `isCurrent` then answers /// false for every genuine manifest, which trains operators to ignore the /// one warning that matters. /// /// It also falsified the reason given for a single-step /// `transferPublisher`: "the worst a wrong publisher does is refuse to /// publish". The worst it did was make publishing impossible for everybody, /// including the key that replaces it. /// /// A million is far beyond any versioning scheme this will use (the /// manifest version is a build counter) and it is small enough that /// exhausting uint64 takes more publishes than the contract will ever see. uint64 internal constant MAX_VERSION_JUMP = 1_000_000; /// Name a new manifest as current. /// /// The version must INCREASE. Equal is refused along with lower: republishing /// the same number with different bytes would make the version meaningless as /// an answer to "which manifest is this", which is the only question it is /// asked. /// /// And it must increase by a BOUNDED amount — see MAX_VERSION_JUMP for the /// one-way door that had no ceiling. function publish(uint64 version, bytes32 manifestHash, string calldata uri) external onlyPublisher { // A zero hash matches nothing a real file hashes to, so accepting one // would leave every check failing with no way to tell "not published // yet" from "published as empty". if (manifestHash == bytes32(0)) revert EmptyHash(); if (version <= _current.version) revert NotNewer(_current.version, version); if (version - _current.version > MAX_VERSION_JUMP) { revert JumpTooLarge(_current.version, version, MAX_VERSION_JUMP); } _current = Release({ manifestHash: manifestHash, version: version, publishedAt: uint64(block.timestamp), uri: uri }); emit Published(version, manifestHash, uri); } /// Hand publishing to another key. /// /// Single-step, and the reason is that this key cannot do damage worth a /// two-step handover: the worst a wrong publisher does is refuse to publish, /// which shows up as a console that stops offering updates rather than as /// money moving. AgentRegistry's roles are two-step because theirs can. /// /// THAT ARGUMENT RESTED ON A WORST CASE THAT WAS WRONG until 2026-09-10. /// The version's monotonicity check had no ceiling, so one publish at /// `type(uint64).max` made publishing impossible for every later key /// including this one — not "refuses to publish" but "nobody can, ever". /// MAX_VERSION_JUMP is what makes the sentence above true, and it is the /// reason this stays single-step rather than the reason it should. function transferPublisher(address to) external onlyPublisher { if (to == address(0)) revert ZeroPublisher(); emit PublisherTransferred(publisher, to); publisher = to; } function current() external view returns (Release memory) { return _current; } /// The one call a console needs. /// /// Answers false before anything is published, which is the safe direction: /// a chain with no published manifest offers no updates rather than /// accepting any file handed to it. function isCurrent(bytes32 manifestHash) external view returns (bool) { return _current.manifestHash != bytes32(0) && _current.manifestHash == manifestHash; } function currentHash() external view returns (bytes32) { return _current.manifestHash; } function currentVersion() external view returns (uint64) { return _current.version; } }