Skip to content

feat(access-v2): unified membership authority and safe migration - #193

Open
hudsonhrh wants to merge 102 commits into
mainfrom
hudsonhrh/role-customization-via-vote
Open

feat(access-v2): unified membership authority and safe migration#193
hudsonhrh wants to merge 102 commits into
mainfrom
hudsonhrh/role-customization-via-vote

Conversation

@hudsonhrh

Copy link
Copy Markdown
Member

Summary

  • replace the per-module Hats authorization path with a per-org MembershipAuthority and AuthorityRouter while retaining dual-path compatibility for migration
  • add authority-native OrgDeployer roles, groups, permissions, memberships, module wiring, and RoleManager-compatible governance surfaces
  • add deterministic, governed migration tooling, verification, differential/acceptance coverage, and the KUBI role-customization rollout
  • preserve canonical role names, metadata CIDs, and images across legacy migration and new v2 deployments

Metadata correctness

Legacy EligibilityModule stores a nonzero metadata CID in Hats.details, so treating details as a role name can persist a raw digest. Migration now folds HatMetadataUpdated events into reviewed fixtures, rejects missing or drifting data, filters metadata pointers and legacy image sentinels, and uses the existing renameSubject path to preserve metadata without adding a migration-only ABI.

Already-seeded but paused migrations are repaired idempotently at the start of cutover. Native v2 deployment also persists role CID/image values while the authority remains paused. OrgDeployer now reports VERSION 2.0.0 to mark the breaking deployment ABI boundary.

Validation

  • full DeployerTest: 25/25 passed
  • migration metadata regressions: 8/8 passed
  • Access-v2 acceptance, differential, authority, router, module, and migration suites passed during the branch rollout
  • current-state KUBI 13-call cutover artifact replayed successfully on an isolated Gnosis fork
  • production OrgDeployer runtime: 23,514 bytes, 1,062 bytes below EIP-170
  • conservative 16-role/full-metadata deployment: 15,908,310 gas against the current 17,000,000 Gnosis block limit
  • production build, forge fmt --check, shell syntax checks, and git diff --check passed

Rollout notes

  • This is the complete Access-v2 contracts branch, not only the metadata display repair.
  • Deploy/register the v2 implementations and use the matching frontend/subgraph PRs before native v2 org creation.
  • KUBI seed proposals 25-29 are already executed. Only a freshly regenerated cutover artifact with the metadata-repair prefix should be used.
  • No migration transaction is performed by this PR.

hudsonhrh and others added 30 commits August 13, 2026 12:58
…hadowing audit (W4)

Append `address configAdmin` at each module's ERC-7201 Layout tail with an
executor-gated setConfigAdmin(address) + ConfigAdminSet event. Widen auth to
`executor || configAdmin` only on: TM setConfig ROLE_PERM branch (all other
ConfigKey branches stay executor-strict), PT setMemberHatAllowed/
setApproverHatAllowed, Edu setCreatorHatAllowed/setMemberHatAllowed, and QJ
updateMemberHatIds. Enables a RoleManager to fan out role wiring without
being the executor. Adds read-only AuditProjectRolePermShadowing script that
reports per-project ROLE_PERM overrides shadowing global masks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New per-org BeaconProxy module implementing first-class named roles and role
groups, driven entirely through governance (onlyExecutor on every mutation,
zero user-facing mutating functions). Creates identity/marker hats via the
EligibilityModule, fans typed permission wiring out to sibling modules
(DD/HV/TM/PT/Edu/QJ) + per-hat paymaster budgets, and implements the consent
model: in-org members are minted directly, non-members receive an explicit
eligibility offer (RoleOffered) they accept via EM.claimHats. Revocation is a
single clearWearerEligibility on the defaultEligible=false identity hat, with
group markers auto-following via derived eligibility (no burn).

- src/interfaces/IRoleManager.sol: frozen structs/events/ABI (W2 section)
- src/RoleManager.sol: ERC-7201 storage, _disableInitializers, initialize
  mirrors seed events
- test/RoleManager.t.sol + mocks: 24 tests (lifecycle, consent matrix, wiring
  fan-out, derived-revocation, registerExisting, auth)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss edits (W3)

Additive-only ABI for RoleManager Phase 2. Existing createProposal
selectors/events/behaviour are byte-identical; new surface lives in side
mappings appended at each Layout tail (never on the Proposal struct).

DirectDemocracyVoting:
- createProposalV2(..., uint32 quorumOverride): restricted-only override;
  executable proposals raise-only (effectiveQuorum = max(global, override),
  crit-security H-2), signal polls may lower.
- setConfigAdmin + configAdmin gate on setConfig(HAT_ALLOWED) only.
- ProposalConfigV2 event alongside NewProposal/NewHatProposal.

HybridVoting (+ libs):
- createProposalV2(..., uint32 quorumOverride, bool equalWeight): equalWeight
  snapshots one synthetic DIRECT class {slicePct:100, hatIds: pollHatIds} so
  vote()/announceWinner() are untouched and tally is one-person-one-vote.
- addHatToClass/removeHatFromClass (executor||configAdmin, ClassHatSet event,
  bounds-checked, slices untouched); setCreatorHatAllowed/setClasses accept
  configAdmin; setConfigAdmin executor-only.
- announceWinner honours the effective quorum.

Tests: DirectDemocracyVotingV2.t.sol (17) + HybridVotingV2.t.sol (24) cover V1
regression, H-2 raise-only, signal-poll lowering, equalWeight one-person-one-vote
vs whale control, class-edit auth matrix + bounds, and storage-layout safety.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er authority (W1)

Adds the 4th eligibility path (derived/group membership) plus a scoped
RoleManager admin surface to EligibilityModule, per RoleManager Phase 2 W1.

- getWearerStatus gains an additive derived branch (mirrors the email
  branch): a group marker hat with a member-hat list makes any wearer of a
  listed identity hat eligible+standing, unless an explicit per-wearer rule
  (kick) says otherwise. Hats with no derived config are byte-identical.
- setGroupEligibility with bidirectional derived<->vouch guard (M-03 pattern)
  and flat one-level nesting via groupMembershipRefCount (cycle/OOG safety).
- claimHat / claimHats(uint256[]) permissionless self-mint requiring a
  SPECIFIC eligibility source (explicit rule / vouch quorum / email /
  derived) — bare default-open reverts NotClaimableHat (H-03). claimHats is
  the offer-acceptance path (identity+markers in one tx, cap 20).
- Scoped onlySuperAdminOrRoleManager gate on the whitelisted wiring/config
  functions only; setRoleManager stays superAdmin-only; grantWearerEligibility
  is grant-only (true,true), bans remain superAdmin. Adds updateHatConfig and
  createHatWithEligibilityChecked (expectedHatId drift guard).
- Storage appended after emailVerified (roleManager, groupMemberHats,
  groupMembershipRefCount). Heavy logic offloaded to a linked delegatecall
  library (EligibilityLogic) to stay under EIP-170 (module 22,522 B, +2,054).
- Tests: EligibilityModuleDerived.t.sol (36) + MockHatsEligibilityAware
  (faithful eligibility!=balance model). Updated pre-existing NotSuperAdmin
  assertions to NotSuperAdminOrRoleManager for the re-gated functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pped (integration fix)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opt-in RoleManager module on the atomic org-deploy path, mirroring the
ZkEmailInvites pattern (beacon-gated, register-before-initialize).

- ModuleTypes: add ROLE_MANAGER_ID = keccak256("RoleManager").
- ModulesFactory: RoleManagerConfig{enabled}; deploy the proxy UNINITIALIZED,
  gated on config.enabled AND beaconRegistered(ROLE_MANAGER_ID) (skip, never
  brick the org deploy); include it in the batch registration. Initialization
  is deferred to OrgDeployer because RoleManager.initialize needs the DD/HV
  voting addresses, which are deployed after ModulesFactory returns.
- Executor: configureModule(target,data) onlyOwner — generic bootstrap relay
  (C-01 pattern; dead on live orgs where owner()==0). Emits ModuleConfigured.
- OrgDeployer: deployFullOrgWithRoleManager sibling entrypoint (DeploymentParams
  tuple untouched); DeploymentResult gains roleManager. After voting deploy and
  before renounce, initialize the RoleManager and wire scoped authority through
  the Executor: EM.setRoleManager + setConfigAdmin on DD/HV/TM/PT/EduHub/QuickJoin.
- HatsTreeSetup: decouple deploy-time minting from canVote (mintToDeployer /
  additionalWearers mint regardless of canVote); OrgDeployer._collectInitialWearers
  mirrors this so the InitialWearersAssigned event stays consistent.
- Tests: test/OrgDeployRoleManager.t.sol (9) — registration under ROLE_MANAGER_ID,
  seeded orgHats init, EM.roleManager + configAdmin on all six modules,
  register-before-initialize ordering, disabled-config parity, beacon-unregistered
  skip, EduHub-disabled wiring, and the canVote=false+mintToDeployer mint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ogic layout-sync guard (W6)

Salvaged from interrupted W6 agent worktree; verified 20/20 passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- revokeRole: burn via checkHatWearerStatus (frees capped-hat supply for
  elections), reconcile group markers, revert RevokeIneffective when
  eligibility persists (default-eligible/vouch/email) instead of emitting
  a false RoleRevoked
- _applyWiring guards: reject vouch+combine=false (breaks grant/offer
  model), vouching on group markers, and quickJoinAutoMint on
  default-closed hats (would brick QuickJoin joins)
- offer branch clears stale explicit marker rules so one-tx claimHats
  acceptance survives old kicks
- EligibilityModule: distinct WearerEligibilityCleared event (clears were
  log-indistinguishable from bans)
- RoleManager: ModulesWired init event + modules() view (fan-out targets
  reconstructable from logs)
- tests: capped-role election supply regression, RevokeIneffective on
  adopted default-eligible roles, offer-after-kick claim, wiring guards,
  tightened GroupCreated assertion, PT/Edu/QJ non-widened-setter negatives

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AdoptRoleManagerTest6.s.sol adopts the RoleManager module into the live
Test6 org (Gnosis). Three contracts per PLAN §2.3 / deploy-flow §3:

- PredeployTest6 (EOA): SwitchableBeacon(executor, protocolBeacon, 0, Mirror)
  + uninitialized BeaconProxy.
- BroadcastAdoptTest6: the single 9-call governance batch (registerOrgContract
  -> RoleManager.initialize -> EM.setRoleManager -> setConfigAdmin x6 on
  DD/HV/TM/PT/EduHub/QuickJoin), submitted via HybridVoting.createProposal.
  Includes the C1 pre-flight uninitialized-proxy guard.
- SimAdoptTest6: full Gnosis fork sim — in-fork protocol prereqs (Satellite
  addContractType + Mirror beacon wave, pranked as the real owner), predeploy,
  create/vote/warp/announceWinner (real creator-hat wearer, explicit 8M gas
  stipend), post-asserts (RegisteredContract, modules() match, EM.roleManager +
  6 configAdmins == proxy), and a createRole("Canary") smoke via a 2nd proposal.

Sim PASS under FOUNDRY_PROFILE=production; adoption announceWinner used 977,855 gas.
Test6 modules verified Mirror-mode; batch <= 20 calls, no Executor self-target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register the RoleManager module at the protocol level per-chain and sponsor
the EligibilityModule self-claim path in the global rulebook.

- script/rolemanager/RegisterRoleManagerProtocol.s.sol: per-chain (Gnosis via
  Satellite, Arbitrum via Hub, both LOCAL — never Hyperlane) CREATE2-deploy the
  RoleManager impl (computeSalt("RoleManager","v1")), addContractType (creates the
  global beacon + registers v1), deploy the RoleManager-aware ModulesFactory +
  OrgDeployer (v21) and setModulesFactory / upgradeBeacon. Broadcast + Sim pairs;
  the Sims prank Hudson and assert getBeaconById / getCurrentImplementationById /
  modulesFactory-slot before AND after. Versions dual-surface probed FREE on both
  chains (RoleManager v1, OrgDeployer/ModulesFactory v21).

- script/helpers/DefaultGlobalRules.sol: add (ELIGIBILITY_MODULE_ID, claimHat) and
  (…, claimHats) entries (52 -> 54) with batch-sized gas hints (300k / 3M for the
  up-to-20-mint loop); selectors derived from the real ABI, trailing assert honest.

- script/rolemanager/SyncRoleManagerGlobalRules.s.sol: broadcast the two-rule delta
  (read from DefaultGlobalRules so hints stay single-sourced) via Satellite/Hub
  adminCall; Sim stands up v20 on the fork (live hub is still v19), seeds the pre-W7
  baseline, then asserts getGlobalRule before/after with the correct Rule field order
  (maxCallGasHint, allowed) and count 52 -> 54.

- test/PaymasterGlobalRules.t.sol: extend the selector-bijection expectation to 54.

Sims run under FOUNDRY_PROFILE=production against gnosis + arbitrum forks — all PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add script/rolemanager/UpgradeModulesForRoleManager.s.sol upgrading the 8
impls changed by the RoleManager feature (EligibilityModule, DirectDemocracy-
Voting, HybridVoting, TaskManager, ParticipationToken, EducationHub, QuickJoin,
Executor) across Gnosis (Satellite-local) and Arbitrum (Hub-local).

- Dual-surface version probe (registry + CREATE2) per type, both chains;
  chosen versions recorded in the header (EM v8, DDV/HV v13, TM/PT/QJ v8,
  EduHub v4, Executor v5) — all FREE on both chains, identical CREATE3 addrs.
- External libs (EligibilityLogic; HybridVoting's 3 libs) auto-linked into
  type().creationCode by Foundry, deployed ahead of impls — same convention as
  UpgradeGovernanceSecurity.s.sol; CREATE3 keeps impl addrs chain-independent.
- Broadcast steps (Gnosis deploy, Arbitrum cross-chain upgrade, Gnosis direct
  upgrade, verify) + SimGnosis/SimArbitrum pranking Hudson.
- Sims assert per type: getCurrentImplementationById flips AND a live Mirror
  org proxy (Test6/Gnosis, Poa/Arbitrum) follows the beacon with byte-identical
  storage-backed reads; EM roleManager()==0 + getWearerStatus unchanged proves
  the derived-eligibility path is inert-until-configured.

Both sims PASS under FOUNDRY_PROFILE=production on live gnosis/arbitrum forks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…riant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hority, registration guards

- DefaultGlobalRules 54->56: sponsor DD/HV createProposalV2 (passkey users
  keep gasless restricted-poll creation); sync-script delta generalized to
  the 4 RoleManager-wave rules via _isDeltaEntry
- OrgDeployer: grant+mint the org's paymaster operator hat to RoleManager
  at deploy (setBudget is onlyOrgOperator — budgets were silently
  BudgetSkipped); best-effort with RoleManagerOperatorHat event
- RoleManager: maxSupply 0 => unlimited (raw 0 is unmintable in Hats);
  duplicate initial group members revert AlreadyInGroup; one role per hat
  (HatAlreadyRegistered); one group per marker + role/marker hats mutually
  exclusive via groupIdOfMarker index (MarkerAlreadyRegistered)
- tests: 6 new guards; PaymasterGlobalRules bijection 54->56

All 7 production fork sims re-run PASS on both chains after the changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ogic lib

Move _applyWiring/_applyQuickJoin into a new delegatecall library
(src/libs/RoleManagerLogic.sol) with a byte-identical ERC-7201 Layout
mirror + functional sync-guard test; RoleManager runtime drops from
23,061 B (margin 1,515) to 19,258 B (margin 5,318) at runs=200.

Add ManagerConfig storage (roleManagerConfigs/groupManagerConfigs) and
onlyExecutor setRoleManagerConfig/setGroupManagerConfig with the
SelfManagedGroup direct-cycle guard, re-checked in addRoleToGroup.

grantRole/revokeRole gain delegated auth (executor OR wearer of the
role's or any containing group's managerHat with the needed cap) +
nonReentrant. Delegated paths are provenance-guarded via
EM.getWearerRuleFlags: grant reverts GrantBlockedByGovernanceBan when it
would overwrite a governance-owned ban (may clear a 0x04 delegated kick);
revoke refuses to clear a governance-written (true,true) offer. Executor
paths are byte-identical to today.

REPLACED RoleGranted/RoleOffered/RoleRevoked event signatures with
actor+delegated fields (pre-broadcast, per INTERFACES.md W13) and add
RoleManagerConfigSet/GroupManagerConfigSet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e, kick delegation

FIX 0 (explicit-ban supremacy): EligibilityLogic._getWearerStatus short-circuits
to (false,false) on any explicit rule with bits 0-1 clear, before the vouch combine,
so a governance ban (or delegated kick) beats vouch/email/derived/default on EVERY
path. Mirrored in _hasSpecificEligibilitySource. Explicit (true,true) keeps additive OR.

FIX 1 (provenance): WearerRules.flags bit2 (0x04) = DELEGATION_MANAGED. Set by kicks,
unkick restore, and RM-mediated grantWearerEligibility; auto-cleared by every direct
superAdmin writer (correct-by-construction via _packWearerFlags). New getWearerRuleFlags
view for RoleManager (W13) provenance checks.

Primitive B (kick delegation): KickConfig{kickerHatId,delaySecs,enabled} + pending-kick
lifecycle — configureKick/kickWearer/finalizeKick/cancelKick/unkickWearer. Governance
supremacy (CannotKickGovernanceRuled), effect delay (pending kicks die with the delegation),
honest KickIneffective belt, exact-0x04 unkick restoring (true,true|0x04). All kick bodies
in EligibilityLogic; EM has thin wrappers. Rule writes void pending kicks.

Sizes (production): offloaded claimVouchedHat + vouchFor/revokeVouch bodies to the lib to
hold margin — EligibilityModule 21,516 B (margin 3,060), EligibilityLogic 9,429 B
(margin 15,147).

Tests: new EligibilityModuleDelegation.t.sol (35 tests: supremacy truth table, provenance
lifecycle across all six writers, kick governance-supremacy, immediate/delayed/cancel/unkick,
vouch survival + KUBI regression); extended EligibilityLogicLayoutSync for kickConfigs/
pendingKicks + RM-grant 0x07. Updated DeployerTest.testVouchingSystemHybridMode Test 3
(asserted the now-fixed OR bug).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pe, Test6 batch v10

- DefaultGlobalRules: kickWearer/finalizeKick/unkickWearer (EM) +
  grantRole/revokeRole (RoleManager) with measured gas hints
- OrgDeployer._buildTargetTypes: ROLE_MANAGER_ID branch (delegated
  lifecycle calls are user-facing now)
- AdoptRoleManagerTest6: 10-call batch adds setTargetTypesBatch for the
  RM proxy; sim stands up hub v20 in-fork (broadcast order unchanged)
- SyncRoleManagerGlobalRules: delta generalized to the 9 wave rules
- tests: bijection 56->61, hinted-entry set, RM target-type coverage

Full suite 2028 green; all 7 production fork sims re-run PASS.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rototype

Head-to-head gas benchmark for the "remove Hats + unified authority"
blank-slate decision (.context/rolemanager/blank-slate-review.md, PLAN.md).

- test/AccessV2GasBench.t.sol: measures 5 hot paths against the REAL wiring
  (forked Gnosis Hats + real EligibilityModule/EligibilityLogic + real
  RoleManager) and the same 5 against the prototype — single-check, derived
  group check (n=1/3/5), DD/HV vote hasAnyHat (n=2/5/10), permMask, and
  grant/revoke lifecycle — reporting cold and warm gas.
- test/mocks/NativeLedgerPrototype.sol: faithful minimal collapsed authority
  (membership = accepted-bit && eligible-policy, the full five-path resolution
  ported by logic shape from EligibilityLogic, role/group registry, permTable,
  isMember/hasPerm surface, ERC-7201 storage, events on writes). Production
  runtime 7,517 B.
- test/mocks/CurrentPathHarness.sol: real HatManager.hasAnyHat + verbatim
  TaskManager._permMask over real Hats (bodies otherwise internal-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extends the round-1 collapsed-authority floor bench into a production-weight
prototype (MembershipAuthorityProto) implementing every ACCESS-V2-SPEC v1.3
shape that moves gas/size, and re-measures against the real Hats/EM/RM path.

- §3 inverted-fold hasPerm (subjectsWithKey index, ctx∪global deduped union,
  packed {exists,inheritGlobal,254-bit value} word, permKey-top-byte fold tag);
  measured at fan-outs 2/8/32 with role+group mix, groups at GroupSizeLimit.
- §3 activeMemberSince(subject) + key-folded activeMemberSince(user,key,ctx).
- production weight: ERC-1155 TransferSingle + six-plus-RoleClaimed lifecycle
  events, pause + reentrancy guards, executor-root + ManagerConfig scoped
  access, unified pending-action table (create/finalize/cancel) with delaySecs,
  memberCount/maxMembers, caps (16 roles/user, 16 members/group, 8 groups/role,
  16 subjects/key) enforced with named errors, IHats read-subset + viewHat(9).
- re-measured round-1 ops + delegated grant (pending create+finalize), claim
  (offer-accept), reconcile; warm+cold columns.

Size (production profile): 16,808 B runtime — 7,768 B under EIP-170, ~4.2 KB
under the spec's 21 KB lib-split trigger; one contract fits with margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Protocol-owned singleton router per ACCESS-V2 freeze §2 + ruling R5:
- TOTAL structural classification predicate (legacy ≥2^224 bound→authority /
  unbound→Hats passthrough; v2 embedded-address self-route; codeless/malformed
  →empty resolution; sub-2^64 garbage→safe-zero passthrough)
- OrgRegistry-gated legacy bind/unbind with topHat-domain verification (executor
  via OrgRegistry.orgOf, domain vs getTopHat>>224)
- served IHats read subset (isWearerOfHat/isEligible/balanceOf/balanceOfBatch/
  getWearerStatus/viewHat 9-field/checkHatWearerStatus)
- msg.sender==PaymasterHub revert-wrapping (clean false/0 for the hub; honest
  propagation for others; v2 self-routes always empty-on-fail)
- R5: checkHatWearerStatus passthrough uses CALL not STATICCALL (declared
  non-view; selector identical, ABI unchanged)

Router runtime size 6,437 B (production) — well under the 21KB lib-split
trigger; no delegatecall partition needed.

test/AuthorityRouter.t.sol: 46 tests — full degenerate-input matrix, bind auth
(real OrgRegistry fixture, spoof/domain-mismatch rejection), passthrough
byte-parity, hub-wrapping differential, unbind/re-bind rollback,
unbind-mid-batch, two-org routing isolation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eze §0/§1/§3/§5)

Implements the frozen MembershipAuthority build contract (ACCESS-V2-INTERFACES.md,
ACCESS-V2-SPEC.md v1.3): one per-org ERC-7201 BeaconProxy authority folding membership,
the tri-state eligibility resolver, the inverted semantic-perm fold, unified pending-action
delegation, seeds/migration and the Hats/ERC-1155 read surface.

- Shared types/ids/perm-key libs (AccessV2Types/Ids/PermKeys); packed Membership slot;
  authored-by + delegable rule slot; vouch attestor with epochs/rate limits/admin surface;
  email attestor; per-subject defaults with force flag.
- Perm table: packed word, subjectsWithKey inverted index, ctx∪global dedup union fold,
  both activeMemberSince variants, canGrant/canRemove/canClaim preflights with lapsed hints.
- Delegation: ManagerConfig, monotonic pendingId, SelfManagedGroup guard, NotYetActive anchor,
  finalize/cancel/void; soft/hard removal with RemovalIneffective(sourceSet) + SubjectFull hint.
- Pause redefinition (executor writes exempt), born-paused initialize mirroring all seed events,
  IHats read subset incl. IHats-shaped mintHat, ConfigLint events, R3 ops views.

Size gate (FOUNDRY_PROFILE=production, EIP-170 24,576): the combined cold surface exceeded the
ceiling (single lib 24,975 B), so it is split across TWO delegatecall libs sharing the ONE canonical
Layout: MembershipAuthority hub 23,301 B, MembershipAuthorityLogic 20,431 B, MembershipAuthoritySeed
9,449 B. Slot-mirror sync test pins the ERC-7201 constant across all three units.

Tests: fold truth table, membership state machine, pending lifecycle, caps + SubjectFull hint +
reconcile, seeds-while-paused, emission-contract conformance, union fold with inherit flags, id
arithmetic + legacy adoption + two-authority subjectKey distinctness, activation gate, layout sync.
55 new tests green; full suite 2085 passed (--no-match-contract DeployerTest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rity remap

Wave B workstream B3: remap EducationHub, QuickJoin, Executor and PaymasterHub
onto the MembershipAuthority per ACCESS-V2-INTERFACES §4.5–§4.8.

- EducationHub: append `membershipAuthority` + executor-gated setMembershipAuthority;
  creator/member gates dual-path to EDU_CREATE / EDU_MEMBER hasPerm folds when set,
  byte-identical legacy Hats path when zero.
- QuickJoin: append `membershipAuthority` + setter; join path enumerates the
  QJ_AUTOJOIN default-ALLOW subjects via subjectsWithKey and mints through the
  unchanged Executor loop; legacy memberHatIds path byte-identical when unset.
- Executor: setMembershipAuthority repoints the `l.hats` slot itself (§4.7), stashing
  the original in an appended `legacyHats` field so setMembershipAuthority(0) restores
  it (rollback). Selector added to the execute() self-target allowlist so a renounced
  org can repoint through governance. Emits HatsRepointed.
- PaymasterHub: one change — protocolAdmin/poaManager-gated setHats(address) + HatsSet
  event (§4.8 / §6 step 0.3 router repoint); validation semantics untouched.

Dual-path + regression + rollback tests per module; QJ join equality differential
(legacy hats list vs authority QJ_AUTOJOIN subjects → same mints); Executor
repoint/restore; hub setHats auth matrix. All storage appends are ERC-7201 tail-only.

Production --sizes (runs=200, --skip test): EducationHub 9,107 / margin 15,676;
QuickJoin 10,665 / 14,118; Executor 7,439 / 17,344; PaymasterHub 22,602 / 1,974
(+184 B vs on-branch baseline 22,418). Full suite: 2164 passed, 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Append MembershipAuthority dual-path to TaskManager and ParticipationToken
per ACCESS-V2-INTERFACES.md §4.1/§4.4/§4.5. When membershipAuthority == 0
each module reads its legacy Hats path byte-identically (rollback state);
when set, permission reads route through the authority.

TaskManager:
- Layout tail: append `address membershipAuthority`
- setMembershipAuthority(address) onlyExecutor + MembershipAuthoritySet event
- _permMask authority arm: uint8(hasPerm(user, TM_PERMS, bytes32(pid)))
  (OR-mask fold; §3 CTX RESOLUTION replaces the v1 REPLACE-shadow footgun)
- creator/organizer checks fold isMember over the existing subject-id
  enumerations (hat ids adopted verbatim, §1) so migrated orgs keep working
- getLensData variant 12 surfaces the pointer
- production runtime 22,013 B / 2,563 B margin (was 21,168 / 3,408; +845 B, no offload)

ParticipationToken:
- Layout tail: append `address membershipAuthority`
- setMembershipAuthority(address) onlyExecutor + MembershipAuthoritySet event
- membershipAuthority() getter
- _hasHat authority arm: hasPerm(user, PT_MEMBER|PT_APPROVE, GLOBAL) != 0
- production runtime 15,272 B / 9,304 B margin

Tests (built against the REAL MembershipAuthority):
- legacy regression, authority-path mask parity (global/project-replace/inherit),
  §5.2 #8 effective-mask equality differential (legacy vs authority, exhaustive
  over (user, project)), creator/organizer routing, PT member/approver dual-path,
  setter auth + rollback-to-zero.

Existing TaskManager/ParticipationToken suites unchanged and green; full suite
2147 passed (DeployerTest excluded per fork-RPC noise).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…W-B1)

Wave B workstream B1: remap DirectDemocracyVoting + HybridVoting onto the
MembershipAuthority behind the dual-path invariant. `membershipAuthority == 0`
reads the legacy Hats path byte-identically (unmigrated orgs / rollback);
when set, permission reads route through the authority per the frozen key
shapes plus the electorate activation gate.

DD (src/DirectDemocracyVoting.sol):
- Layout tail: `membershipAuthority` + `proposalCreatedAt` side-mapping.
- executor-gated setMembershipAuthority + MembershipAuthoritySet event.
- create → hasPerm(DD_CREATE); vote → activeMemberSince(DD_VOTE) <= createdAt.
- restricted polls: pollHatIds reused id-agnostically as subject ids, each
  activation-gated via activeMemberSince(subject, voter) <= createdAt.

HV (src/HybridVoting.sol + Core/Config/Proposals libs):
- Layout tail (Proposal struct + ClassConfig stride NEVER touched):
  membershipAuthority, classSubjectSeq, classIdOfIdx, classSubject,
  proposalClassSubjects side-mapping, proposalCreatedAt.
- setClassSubject(classIdx, subjectId) [executor||configAdmin] with frozen
  stable-classId allocation; classIdOfIndex/classSubjectOf/proposalClassSubject
  views; ClassSubjectSet event.
- create → hasPerm(HV_CREATE); class membership → immutable per-proposal
  subject snapshot + activeMemberSince(sid) <= createdAt (group-packer caught);
  legacy hat-gated power path unchanged when authority is 0.
- DEVIATION (documented): class membership resolves via the per-proposal
  snapshot ONLY (no live classSubject fallback at vote time) so the binding is
  immutable across later setClassSubject edits (§5.2 snapshot-immutability
  obligation); snapshot-0 classes / equalWeight fall back to hatIds-as-subjects.

Tests: per-module dual-path suites (legacy regression stays green, 2144 pass):
creator gating, the mid-proposal activation gate (excluded from the open
proposal, eligible on the next), restricted-poll subjects, HV class-subject
snapshot immutability + allocation semantics, legacy↔authority equality
differential, setMembershipAuthority auth + rollback-to-zero. Adds
test/mocks/MockMembershipAuthority.sol.

Sizes (production, runtime): DD 14,013 B (10,563 margin), HV 9,729 B
(14,847 margin) — inline, no lib offload needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hudsonhrh and others added 28 commits August 25, 2026 20:49
… read path

MainDeploy (home + satellite) and DeployInfrastructure initialized the
PaymasterHub with real Hats and never deployed an AuthorityRouter, so on any
chain stood up by these scripts every Wave-F org's admin surface was dead on
arrival: the hub's adminHatId is a new-style authority subject id (< 2^224),
which real Hats resolves to balance 0. withdrawOrgDeposit (no poaManager
bypass), setPause and setOperatorHat all reverted NotAdmin forever.

- DeployHelper._wireAuthorityRouter: deploy the router singleton (ERC1967Proxy,
  fully initialized — never an uninitialized proxy) and repoint the hub via the
  poaManager-gated setHats. Documents why the hub cannot simply be *initialized*
  with the router: the router's own initializer needs the OrgRegistry and hub
  addresses, so the cycle is broken by init-then-repoint.
- Register the AuthorityRouter impl (provenance, mirroring the live ceremony)
  and — mandatory, previously missing — the MembershipAuthority type, without
  which AccessFactory.deployAuthority reverts and no org can deploy at all.
- Serialize the router address into the deploy state files.
- DeployerTest: end-to-end coverage with the router in the loop — a
  deployer-produced org's ADMIN-subject wearer drives setPause/setOperatorHat
  through the hub, a stranger still cannot, and the pre-repoint revert is
  pinned.

Fork-simulated on a Gnosis fork: DeployInfrastructure and MainDeploy:DeployHomeChain
both complete, including a full governance-org deploy (15 contract types).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…2 + ceremony)

OrgRegistry.updateOrgMetaAsAdmin authorizes against `l.hats`, which was written
only at initialize with no setter. Every new-style org seeds its metadata-admin
hat with an authority SUBJECT id (< 2^224) that real Hats resolves to balance 0,
so the direct (non-governance) org-metadata edit path was dead for them on every
chain — while the deployer still wired the feature and the paymaster still
type-mapped OrgRegistry for that very selector.

- OrgRegistry v2: `setHats(address)` mirroring PaymasterHub v20 (zero-check,
  event, admin gate) + a `HatsSet` mirror inside initialize so indexers see the
  deploy-time pointer. The gate is the registry owner (OrgDeployer, or the
  deployer EOA during genesis) OR the PoaManager — resolved as the owner of this
  proxy's own ERC-1967 beacon, which is exactly what Hub/Satellite adminCall
  reaches; the registry stores no poaManager pointer, and a live-chain upgrade
  has no way to seed one.
- Fresh-chain deploys repoint the registry alongside the hub.
- RegisterAccessV2Protocol: OrgRegistry v2 impl (CREATE3), beacon upgrade, and
  one adminCall setHats(router) per chain — upgrade strictly before repoint,
  since setHats ships in v2. Version dual-surface probed FREE on both chains
  (registry count 1, CREATE3 slot 0x72b453a3… empty).
- Both production-profile fork sims re-run and PASS, now asserting the registry
  pointer before/after and driving a live updateOrgMetaAsAdmin as a REAL
  metadata-admin wearer (Test6 / Poa) on both sides of the repoint, with a
  stranger still rejected.
- Runbook step 1 row updated.

Sizes (production, --skip test): OrgRegistry 7,548 B runtime / 17,028 B margin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eploy time

QJ_AUTOJOIN is only reachable for a default-ALLOW role: QuickJoin enumerates
subjectsWithKey(QJ_AUTOJOIN) with no eligibility filter and the authority then
rejects a non-eligible stranger, so a deny-by-default role in
quickJoinRolesBitmap deploys cleanly and then reverts EVERY quickJoin call until
governance intervenes. The deployer is the natural enforcement point — the
pairing is now checked in _validateRoleConfigs with a custom error naming the
offending role index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r-cap up front

Duplicate memberRoleIndices in a GroupConfig reverted SubjectExists, and a
maxMembers cap below a role's genesis wearer count reverted SubjectFull — both
from inside the authority's BeaconProxy constructor, surfacing as opaque deploy
failures rather than the deployer's own validation error. Both are now rejected
in _validateRoleConfigs with custom errors naming the offending indices.

The over-cap pre-check uses the exact seeded count (mintToDeployer +
additionalWearers); a repeated address in that set already fails hard with
AlreadyMember, so the bound has no false positives.

Sizes (production, --skip test): OrgDeployer 23,213 B runtime / 1,363 B EIP-170
margin (was 22,940 / 1,636 before F3+F4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e path

docs/ORG_DEPLOYER.md still walked through the deleted architecture: "Step 6:
Register Hats Tree", registerHatsTree(topHatId, roleHatIds), a DeploymentParams
with the removed defaults/hierarchy/hatConfig fields, and eligibilityModule /
toggleModule wiring. README.md linked to the deleted src/HatsTreeSetup.sol twice
and listed its deployed address; script/README.md claimed the infra script
deploys a "HatsTreeSetup helper" that no longer exists.

- ORG_DEPLOYER.md rewritten against the current 15-step deployFullOrg: subjects
  instead of hats (with the id derivation and seq table), the born-initialized
  authority, the router read path, the current RoleConfig/GroupConfig, the new
  validation errors, and the real OrgDeployed signature.
- README.md: HatsTreeSetup rows/links removed, factory descriptions corrected,
  MembershipAuthority added to the org layer, EligibilityModule/ToggleModule
  marked legacy-only, and the access-control section now leads with Access v2.
- POP_OVERVIEW.md: architecture diagram and role section updated.
- script/README.md: infra list corrected (router + rulebook in, HatsTreeSetup
  out) and the infrastructure.json path fixed.

No dead links remain in the touched files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lobalRules

The comment claimed claimHat/claimHats were dropped because "no chain ever had a
targetTypes row mapping them". That is wrong: targetTypes rows map module
ADDRESSES to typeIds, not selectors, and UpgradePaymasterGlobalRules Step4
backfills every live org's EligibilityModule address, which would make ALL
EM-typed rows resolvable. This file is the declared source of truth for the
setGlobalRulesBatch broadcast, so a false premise here invites a wrong call
during the Wave-G strip.

Replaced with the reasons that actually hold: the v20 type-keyed rulebook has
never been broadcast (both hubs are still v19), the legacy targetTypes backfill
was never broadcast either (Wave-F review finding P5), and the two selectors
exist only on the unshipped EligibilityModule v8 impl — plus an explicit warning
not to generalise the argument to the 8 retained EM rows, which cover selectors
that do exist on live impls. The mirrored comment in
test/OrgDeployerPaymasterRules.t.sol now points at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… summary and infra JSON

MainDeploy no longer serializes a "hatsTreeSetup" state key, so
deploy-testnet.sh printed "HatsTreeSetup: null" for both the home chain and the
satellite. The committed script/config/infrastructure.json also still carried the
key with an old deployed address, misrepresenting the current infra set to
anything consuming that file.

Both summaries now print the chain's AuthorityRouter instead — the address that
actually matters for the Access-v2 read path, and which MainDeploy now writes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dangling provenance pointers only (nothing compiled against the deleted names):
test/AccessV2Acceptance.t.sol cited test/RoleManagerIntegration.t.sol,
script/upgrades/UpgradeOrgDeployerZkEmailRules.s.sol told the operator to land
before broadcasting script/org/DeployComfiestHouse,
test/mocks/MembershipAuthorityProto.sol cited {NativeLedgerPrototype}, and
test/EligibilityModuleM03.t.sol cited HatsTreeSetup.batchSetDefaultEligibility.
Each now describes what the reference was without linking a 404.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…Setup snapshots

upgrades/baseline, upgrades/current and upgrades/previous all still contain
HatsTreeSetup.sol after the source deletion. They are inert — the upgrade-safety
job only activates once .github/upgrades.json exists (it does not, per
.github/workflows/ci.yml), upgrades/ is on no forge build path, and
test/UpgradeSafety.t.sol never reads them.

CLAUDE.md and the README both forbid hand-editing upgrades/ (CI-generated), so
the orphans are left in place and flagged in the README instead, with an explicit
instruction for whoever wires up .github/upgrades.json not to list the dead
contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…it), fresh gas table, W4 ctx docs, CLAUDE.md v2 story

- _lower/_lowerName copied strings instead of mutating in place: the in-place
  aliasing corrupted OrgSpec.name, so the KUBI==5M announceWinner branch never
  matched and its proposal JSONs carried 4M. Verified end-to-end: JSONs now 5M.
- Gas table re-measured on all four orgs (batches grew after A6: KUBI now 5 seed
  proposals, max 2.96M; creation max 5.06M -> wallet floor ~6M); runbook gains
  env pre-flight, exact broadcast entrypoints, per-org chain map, the
  enumerate-tm-perms fixture requirement, the delta-regen seed-file caveat, and
  a frontend go/no-go note for real orgs.
- AccessV2PermKeys + spec: TM per-project ctx documented as projectId + 1 (W4) —
  the constants file taught the pre-amendment convention that would misplace a
  hand-authored project-0 row at GLOBAL scope.
- CLAUDE.md now tells the Access-v2 story (authority model, router, deploy path,
  legacy-arm-until-Wave-G warning); script/README paths fixed; proto mocks
  marked HISTORICAL; error-reuse documented on the frozen interface; stale
  HatsTreeSetup comments scrubbed; OrgRegistry registerHatsTree annotated;
  interfaces/spec context docs gain the ccbc029 event-law amendment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the unpranked deploy path

Running Step1 WITHOUT --broadcast dry-runs successfully and deploys nothing; Step2's
pre-broadcast _deployImpls() re-derivation then fell into dd.deploy with forge's
default sender and died with a cryptic OwnableUnauthorizedAccount (hit live by
Hudson on Gnosis — verified all six CREATE3 slots empty, nothing was broadcast).
Step2/Step2b in both protocol scripts now use _requireImpls (computeAddress +
require code) so the failure is the instruction itself: run Step1 WITH --broadcast.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dow warning

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed decode shipped (PR #485)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…enerated JSON) + VoteMigrationProposal

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eate/vote/wait/announce/verify)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…VATE_KEY first) and fail fast

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ssumed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…DP, POA, KUBI

Resume-safe (skips migrated orgs, idempotent seeds), verifies every proposal via
the Winner(valid,executed) event, settles KUBI's stale proposals, banners the
KUBI second-voter windows, stops hard with plain instructions on any failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…runs later via ORGS=KUBI

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…urce — closes the founder-mint blind spot

The two join-path events cannot see deploy-time founder mints (or admin/EM
direct mints). Caught live post-cutover on Test6: the founder EOA — an
explicitly-allowed, eligible Executive on the legacy EM — was silently not
seeded (v2 Executive 4 vs legacy 5). The subgraph indexes every mint path
since genesis; it is now the third candidate source. Founders verified present
in all four fixtures (KUBI 23->24, Poa 7->10, DP 3->4 candidates).
Repair for Test6: out/test6.repair.1.json (2 calls, run-proposal compatible).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ure message prints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rum 2, only two second-voter asks)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… + 11 seats, one governed batch

- KubiRoles.s.sol: GenerateKubiRoles (writes out/kubi.roles.1.json, 13 calls) + SimKubiRoles
  (full governed migration replay + roles batch via authority-side creator, hard asserts on
  predicted subject ids, group derivation, post-cutover stamps; measured announce 3.89M < 5M)
- migrate-kubi.sh: roles proposal joins round 2 (same window as cutover, announced cutover-FIRST),
  roles-only path for an already-migrated org, RPC fallback (gateway.fm storage flake)
- RehearseMigration.s.sol: T5 shadow probe no longer requires per-row divergence — dual-hat
  wearers legitimately fold to the same word (KUBI: Member proj-2 subset of Executive global-175);
  aggregate shadowChecked>0 still enforces the per-project path (112 rows materialize live)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round 2 start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hudsonhrh

Copy link
Copy Markdown
Member Author

CI note: the current Build & Test and Slither failures occur in the reusable workflow setup before any POP formatting, compilation, tests, or Slither command runs. The published Etherform SHA pins foundry-toolchain v1.8.0, which invokes the newly native foundryup executable through Bash and exits with cannot execute binary file. A rerun reproduced the same setup-only failure. Local production build, focused/full contract suites listed in the PR, formatting, shell syntax, fork replay, size, and gas checks passed. The durable fix is a narrow foundry-toolchain v1.9.1 update in Etherform followed by updating POP to its published Etherform SHA; I have intentionally not coupled this product PR to the unmerged broad dependency-refresh PR BreadchainCoop/etherform#58.

@hudsonhrh

Copy link
Copy Markdown
Member Author

Operational note from the final read-only audit: KUBI currently has proposals #30 (cutover) and #31 (board roles). Both closed with two votes and no Winner event; neither is finalized. MembershipAuthority remains paused. These proposals were created by a separate live migration session while this review was running, not by this PR workflow or its reviewers. No local broadcast-capable migration process is running now. Do not regenerate, vote, announce, or execute from stale local state without first reconciling these two on-chain proposals.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant