Skip to content

feat: governance proposal + objection lifecycle#45

Merged
Skanislav merged 7 commits into
devfrom
feat/governance-proposal-lifecycle
Apr 15, 2026
Merged

feat: governance proposal + objection lifecycle#45
Skanislav merged 7 commits into
devfrom
feat/governance-proposal-lifecycle

Conversation

@Skanislav

@Skanislav Skanislav commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a commitments-only proposal + objection lifecycle to MeetingFactory, then plumbs it all the way through — indexer, typed GraphQL client, agent SDK, frontend hooks, public permalink view, seed data, example script, spec doc — in a single PR so no intermediate state is left half-wired.

This closes the Day 2 sprint finding that blocked the #/o/:orgId/p/:proposalId permalink at the contract layer (no proposal events, no lifecycle). It also unlocks the agent-native story's final beat: an agent can propose a structural change and a human admin can adopt it, and every step is a public, cold-openable link.

Design stance (documented in specs/05-governance-process.md "On-chain Commitments Surface"): IDM rounds, clarifying questions, and integration discussion are coordination — they stay off-chain. What goes on-chain is the audit trail of commitments: who proposed what, who objected when, who adopted or discarded it.

The 5 commits

# SHA Scope
1 73bbcfd contracts: proposal + objection lifecycle on MeetingFactory + 21 forge tests
2 24c988d hollab-indexing: handlers + schema columns + manifest wiring
3 acd2244 indexing-client + agent-sdk: queries, client methods, governance.createProposal etc.
4 10aa7ac hola-modern: PublicProposalView, #/o/:orgId/p/:proposalId route, open proposals panel
5 d12222e seed + example + docs: rename demo org (papercliplantern), 3-state proposal seed, propose-tension.ts swap, spec doc section
6 38ea727 simplification: delete executeGovernance + back-compat layer entirely, migrate SeedDemoOrgs._seedOrg and GovernanceMeetingRoom.buildGovernanceCall to create+adopt, drop legacy tension:text column and assembleOrgManifest overload (-417/+205 lines)

Each commit compiles + tests green in isolation; the branch point between "contract + indexer + SDK" (1–3) and "frontend + seed + docs" (4–5) is clean if a future split is ever wanted.

Contract surface

New on MeetingFactory (all additive, no ABI break on existing methods):

struct ProposalRecord  { id, orgId, circleId, proposer, proposerRoleId, tensionHash, changeType, changeData, status, submittedAt, resolvedAt }
struct ObjectionRecord { id, proposalId, objector, concernHash, status, raisedAt, resolvedAt }

function createProposal(orgId, circleId, proposerRoleId, tensionHash, changeType, changeData) → proposalId
function adoptProposal(proposalId) → resultId
function discardProposal(proposalId)
function raiseObjection(proposalId, concernHash) → objectionId
function resolveObjection(objectionId)
function getProposal / getObjection / proposalCount / objectionCount

event ProposalCreated  (proposalId, orgId, circleId, proposer, proposerRoleId, tensionHash, changeType, changeData)
event ProposalAdopted  (proposalId, orgId, resultId, adoptedBy)
event ProposalDiscarded(proposalId, orgId, discardedBy)
event ObjectionRaised  (objectionId, proposalId, objector, concernHash)
event ObjectionResolved(objectionId, proposalId, resolvedBy)

Design calls

  1. Auth: createProposal / raiseObjection = any org member. adoptProposal / discardProposal = org admin only. resolveObjection = original objector (withdrawal) OR org admin (integration). The resolvedBy in the event lets UIs distinguish the two paths without a separate status.

  2. No adopt-with-unresolved-objections enforcement on-chain. The constitution's objection validity criteria (§5.3.4) are judgment-based; the contract cannot reliably determine them without duplicating the facilitator's role. The event log preserves full auditability. Cheap state-machine invariants are enforced: Draft → {Adopted, Discarded}, Raised → Resolved, no objections on non-Draft proposals, no double-resolve.

  3. changeData stored on-chain in the ProposalRecord, not hash-then-resupply. This matches the agent-native UX ("agent proposes now, admin adopts tomorrow from a different wallet without reconstructing the payload"). Typical CreateRole payload is ~300–800 bytes, ~5–15k gas of SSTORE — acceptable for governance frequency.

  4. bytes32 content-addresses for tensionHash and concernHash. The contract is indifferent to scheme — keccak256(utf8(rawText)) for inline text, CIDv1 sha256-truncated for IPFS, 0G Merkle root for 0G Storage. Keeps ContentRef polymorphic across backends.

  5. uint64 timestamps packed with address in storage — saves a slot per record.

  6. _applyChange helper refactor: executeGovernance extracted its decoder body into a private _applyChange(ChangeType, bytes memory) which both the legacy entry point and adoptProposal call. Legacy executeGovernance is unchanged on the ABI and all pre-existing tests still pass — 73 → 94 forge tests.

  7. No back-compat layer. Originally shipped with executeGovernance marked @deprecated so the merged WS3 seed and demo flows kept working without edits. Simplified in commit 38ea727 — since nothing is deployed anywhere yet, the legacy path is dead weight. executeGovernance is deleted entirely; createProposal + adoptProposal is the only path to structural change. Seed script and authed meeting room were migrated to the new path in the same commit.

Indexer

apps/hollab-indexing/src/proposals.ts is new — 5 handlers, all persisting purely from event args (no contract reads on the hot path, because ProposalCreated carries bytes changeData unindexed).

ponder.schema.ts additions (append-only; local .ponder needs one reset in dev to pick them up):

  • proposal gets orgId, tensionHash, changeType, changeData, changeResultId, resolvedBy — and keeps the legacy tension: text column as empty-string for back-compat
  • objection gets concernHash, resolvedBy

Manifest (apps/hollab-indexing/src/api/manifest.ts):

  • assembleOrgManifest gains a 6th openProposals param via a backward-compat overload so all 39 existing test call sites keep working unchanged. New route handler queries schema.proposal WHERE orgId=? AND status=0 ordered by submittedAt asc.
  • Manifest entries include a path-based permalink (/o/:orgId/p/:id) so agents can follow references without ad-hoc URL construction.
  • 5 new test cases in test/manifest.spec.ts: backward-compat default-empty path, row → manifest mapping, permalink shape, ordering preservation, bigint serialization. Manifest suite 39 → 44.

Hand-maintained apps/hollab-indexing/abis/MeetingFactoryAbi.ts — the 5 new events copied in. This file is a recurring papercut (not imported from @hollab-io/contracts); every MeetingFactory ABI change needs a paired update here.

indexing-client + agent-sdk

  • PROPOSAL_FIELDS / OBJECTION_FIELDS extended with the new columns. Proposal / Objection types updated to match.
  • LIST_PROPOSALS_BY_ORG, LIST_OPEN_PROPOSALS_BY_ORG added. listProposalsByOrg / listOpenProposalsByOrg client methods added.
  • Another BigInt episode fixed: LIST_PROPOSALS_BY_CIRCLE.$circleId and LIST_OBJECTIONS_BY_PROPOSAL.$proposalId were both declared as String! against bigint columns — same Day 1 episode as GET_ORGANIZATION / LIST_ROLES_BY_ORG. Both now BigInt!. New queries start life correctly typed.
  • queries.spec.ts regression set grows from 4 → 14 asserts, including a new PROPOSAL_FIELDS column-coverage table test so dropping a field from the selection is caught the next time someone touches the schema.
  • MOCK_PROPOSAL / MOCK_OBJECTION fixtures in client.spec.ts updated to the new shape so all 42 existing client tests still typecheck.
  • GovernanceModule gains: createProposal, adoptProposal, discardProposal, raiseObjection, resolveObjection, listOpenProposalsByOrg, getProposal. Receipt parsing uses viem decodeEventLog so it survives future ABI reordering (the old executeGovernance path assumed topic[3] was the resultId — fine there but not reusable).
  • Existing execute method marked @deprecated with a pointer to the new path.

Frontend

New hook file useProposalsFromIndexer.ts:

  • useOpenProposalsByOrg(orgId) — Drafts only, submittedAt asc, limit 50
  • useProposalFromIndexer(id) — single fetch by composite <processAddress>-<proposalId>
  • useObjectionsByProposal(processAddress, proposalId) — raisedAt asc

All three go through the typed indexing-client — zero wagmi, zero RPC. Safe to mount above the auth gate.

New view PublicProposalView:

  • Status chip (Draft / Adopted / Discarded) with tone colors
  • Change type label derived from the uint8 enum
  • Tension hash rendered as the content-address (full text resolution is tied to the IPFS/0G backend decision — follow-up)
  • Proposer chip with the existing 🤖 agent allowlist treatment
  • Objections list with per-row raised/resolved state and objector chip
  • Raw changeData pre-block (developer surface; a decoded render needs the ABI + change-type case analysis, deferred)
  • Adopted proposals show their resultId so readers can jump back to the role the proposal created

Composite id resolution: the route carries orgId + proposalId, but the indexer keys proposals by ${processAddress}-${proposalId}. Rather than plumb processAddress through the URL, the view first reads open proposals for the org (the org has at most one MeetingFactory clone) to discover the address, then falls back to a direct getProposal call with the composite id. One extra GraphQL request on cache miss, cleaner than polluting the URL with an address.

Router: useHashRouter gains { page: "publicProposal"; orgId; proposalId }. Parser handles #/o/:orgId/p/:proposalId with the same decodeURIComponent + malformed-fallback pattern as publicRole. App.tsx dispatches the new route in the existing public branch. 3 new tests. useHashRouter suite 16 → 19; hola-modern total 23 → 26.

PublicOrgView: new "Open proposals" section between Members and Roles, rendered when openProposals.length > 0. Links to the permalink via a new onOpenProposal callback, matches the role-card affordance. Shows proposal id + change-type shorthand + proposer chip (🤖 when in allowlist) + tension hash.

Seed + example + docs

SeedDemoOrgs.s.sol:

  • Renames the first demo org from papercliplantern. Paperclip remains the UX inspiration the sprint doc references (paperclip.ing is a real web2 product we're borrowing posture from), but the demo org should not literally share the name. Lantern: illuminates, warm, shareable, no brand collision in org-tooling space.
  • New _seedProposals(lanternId, meetingFactory, agent) appends one proposal in each terminal state on lantern:
    • Adopted: "QA Inspector" role — deployer creates + adopts, flows through _applyChange and appends role feat: governor by OZ + ENS sudbomain #4 to lantern's RoleRegistry
    • Draft + unresolved objection: "Storyteller" role + one raised objection with a keccak256 concernHash
    • Discarded: "Party Planner" role — created then discarded without application
  • Resolves the MeetingFactory clone address by reading RoleRegistry.governanceProcess() — cleaner than parsing the MeetingComponentsDeployed event log in Solidity.

propose-tension.ts:

  • Swaps from executeGovernance(CreateRole) to the full createProposal + adoptProposal lifecycle. Agent signs createProposal (matches the access model: agent can propose, admin adopts). Admin (anvil account #0) signs adoptProposal via a second wallet client.
  • New --draft-only flag stops after createProposal for demos that want to show the adopt step in the UI instead.
  • Tension text is hashed via the SDK's default keccak256 path; raw text stays off-chain.
  • Polls the manifest until the new role appears in roles[], then prints both the proposal permalink (#/o/:orgId/p/:proposalId) and the org page URL.

specs/05-governance-process.md: new "On-chain Commitments Surface" section. Commitments-vs-coordination stance, event → IDM-step mapping table, content-address discipline, authorization table, non-enforcement reasoning, out-of-scope list (process breakdown, integrative election internals, objection validity tests, Withdrawn status), legacy entry point note.

docs/sprint-agent-native-mvp.md: unblocks the /p/:proposalId carry-over bullet with a pointer to this branch.

Live smoke test

Ran end-to-end against a local anvil:

DeployLocal           → ONCHAIN EXECUTION COMPLETE & SUCCESSFUL
SeedDemoOrgs (lantern) → ONCHAIN EXECUTION COMPLETE & SUCCESSFUL

Verified on-chain state directly via cast call:

  • Organization id=2, subname lantern
  • proposalCount: 7 ✓ — 3 baseline role creations + 1 agent Election + 3 proposal-state-demo proposals. After commit 38ea727, every role mutation in the seed flows through createProposal+adoptProposal, so every structural change leaves a ProposalRecord on-chain.
  • objectionCount: 1
  • roleCount: 4 (baseline 3 + the Adopted "QA Inspector") — proves the adopt path flows through _applyChange and mutates RoleRegistry
  • Role feat: governor by OZ + ENS sudbomain #4 = "QA Inspector" with the exact fields from the seed script ✓

Test plan

  • forge test93 passing (was 73; +20 UnitMeetingFactoryProposals cases after commit 38ea727 dropped the legacy regression test)
  • @hollab-io/contracts — forge build clean (only pre-existing erc20-unchecked-transfer warnings on E2EJourney.t.sol)
  • hollab-indexing vitest — 44 passing (was 39; +5 openProposals cases)
  • @hollab-io/indexing-client vitest — 56 passing (was 46; +10 regression asserts on BigInt! + PROPOSAL_FIELDS coverage)
  • @hollab-io/viem-extension vitest — 29 passing (unchanged)
  • @hollab-io/agent-sdk vitest — 4 passing (unchanged — the new write methods are thin viem wrappers, covered by the live smoke test + the propose-tension.ts swap)
  • hola-modern vitest — 26 passing (was 23; +3 useHashRouter cases for the proposal permalink)
  • pnpm check-types — clean across all 4 typechecked packages
  • pnpm lint — clean (4 pre-existing react-hooks warnings in hola-modern, 0 errors)
  • pnpm build — full monorepo build green, hola-modern production bundle builds to IPFS-ready SPA
  • Live smoke test on local anvil: DeployLocal + SeedDemoOrgs both ONCHAIN EXECUTION COMPLETE & SUCCESSFUL, verified lantern.roleCount=4 and "QA Inspector" role exists via cast call
  • Reviewer: pull the branch, ./scripts/dev-local.sh + cd packages/contracts && forge script script/SeedDemoOrgs.s.sol --tc SeedDemoOrgs --rpc-url http://127.0.0.1:8545 --broadcast, then:
    • curl http://localhost:42069/agents/2.json | jq '.openProposals | length' → should be 1 (the Draft)
    • curl http://localhost:42069/agents/2.json | jq '.org.roleCount' → should be 4 (original 3 + the Adopted "QA Inspector")
    • Open http://localhost:5173/#/o/2 in incognito → "Open proposals" panel shows 1 Draft with the 🤖 chip wiring
    • Click the Draft proposal → PublicProposalView renders with the objection row in "raised" state
    • Run AGENT_PRIVATE_KEY=0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6 pnpm --filter @hollab-io/agent-sdk tsx examples/propose-tension.ts 2 → agent proposes, admin adopts, permalink prints
    • Open the printed permalink in a second incognito → renders as Adopted with resultId populated

Follow-ups explicitly out of scope for this PR

  • ContentRef polymorphism + IPFS backend decision — the storage backend question (discussed separately: "not yet, plan for IPFS as the default text path and keep 0G for encrypted blobs"). Tension text resolution in PublicProposalView is a one-line hook addition once the backend lands.
  • Decoded changeData rendering — the view currently shows the raw hex. Decoding needs a change-type switch and the role ABI; deferred to a dedicated polish pass.
  • Authed workspace "Create Proposal" UI — the existing GovernanceView still calls executeGovernance directly. It keeps working (deprecated path), but migrating the authed flow to createProposal + adoptProposal is a separate frontend PR so this one stays reviewable.
  • executeGovernance removalwas marked deprecated-but-working in commits 1-5, now deleted entirely in commit 38ea727. No back-compat scaffolding remains.
  • Process Breakdown, Integrative Election Process mechanics, objection validity tests, Withdrawn status — coordination concerns or post-MVP expansions. Enumerated explicitly in specs/05-governance-process.md "Out of scope (for now)".

🤖 Generated with Claude Code

Skanislav and others added 7 commits April 14, 2026 15:48
Adds a thin commitments-only audit layer on top of the existing
executeGovernance path. New entry points let an agent or member create
a Draft proposal, raise objections against it, and let an org admin
adopt or discard it — with the structural change applied via the same
RoleRegistry path that executeGovernance already used.

Spec stance: on-chain = commitments, off-chain = coordination. The
contract does NOT model IDM rounds, clarifying questions, or
integration. It records who proposed what and when, who objected and
when their objection was resolved, and who adopted or discarded the
proposal. The event log is the auditable trail; the meeting room is
where the actual decision-making happens.

New on MeetingFactory:

- Storage: ProposalRecord and ObjectionRecord mappings + counters,
  appended after _initialized. Safe in clones because each clone gets
  a fresh layout at deploy time. uint64 timestamps pack with address
  to save a slot per record.
- createProposal — any org member. Emits ProposalCreated with the
  full change payload as unindexed bytes so the indexer can persist
  the row from the event alone, no contract reads needed.
- adoptProposal — org admin only. Calls the shared _applyChange
  helper, sets status=Adopted, emits ProposalAdopted.
- discardProposal — org admin only. Sets status=Discarded.
- raiseObjection — any org member. Only allowed on Draft proposals.
- resolveObjection — original objector OR org admin. The resolvedBy
  address in the event lets indexers/UI distinguish withdrawal from
  integration.
- getProposal / getObjection / proposalCount / objectionCount views.

Refactor: executeGovernance extracts its decoder body into a private
_applyChange(ChangeType, bytes memory) helper so both the legacy and
proposal paths run the same code. executeGovernance is marked
@deprecated in IMeetingFactory with a TODO to internally rewrite it as
_create + _adopt once all callers migrate. Calldata→memory copy on the
legacy path costs ~few hundred gas, acceptable for a deprecated entry.

State machine: enforced cheaply (one SLOAD per call) without modeling
the full Holacracy IDM:
- Draft → {Adopted, Discarded}, no double-transition
- Raised → Resolved, no double-resolve
- raiseObjection only on Draft proposals
- adoptProposal does NOT check for unresolved objections — the
  coordinator's job, documented in test_AdoptWithUnresolvedObjections

Tests: new test/unit/MeetingFactoryProposals.t.sol with 21 cases —
happy paths, auth failures (non-member create/objection,
non-admin adopt/discard, non-objector-non-admin resolve), state
machine edges (double-adopt, adopt-after-discard,
discard-after-adopt, objection-on-non-draft, double-resolve,
nonexistent proposal/objection), legacy-path regression
(executeGovernance unchanged after refactor), and AmendRole through
the proposal path to prove the shared helper works for all change
types.

Full forge suite: 94 passing (was 73). Monorepo build green —
regenerated meetingFactoryAbi flows to agent-sdk and hola-modern via
@hollab-io/contracts/actions but no downstream caller has migrated
yet. Indexer ABI hand-copy + handlers land in the next commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…nifest

Wires the new MeetingFactory events (commit 73bbcfd) into the Ponder
indexer so the proposal and objection tables actually populate, and
plumbs open proposals into the agent manifest.

Schema additions (append-only, safe to drop local .ponder on dev restart):

- proposal: new orgId (direct lookups without joining through
  meetingContractIndex), tensionHash (hex, content-address of off-chain
  text), changeType (uint8, mirrors HolacracyTypes.ChangeType),
  changeData (hex, abi-encoded payload), changeResultId (nullable,
  populated on adopt), resolvedBy (nullable). Kept legacy tension:text
  as empty-string for backward compat.
- objection: new concernHash (hex), resolvedBy (nullable — objector on
  withdrawal or admin on integration, lets UI distinguish the two).

ABI copy: apps/hollab-indexing/abis/MeetingFactoryAbi.ts gets the 5 new
events (ProposalCreated / Adopted / Discarded, ObjectionRaised /
Resolved). This file is hand-maintained and NOT imported from
@hollab-io/contracts — documented as a recurring papercut in the sprint
notes; every MeetingFactory ABI change needs a paired update here.

Handlers: new apps/hollab-indexing/src/proposals.ts with 5 ponder.on
subscribers. ProposalCreated carries the full change payload as
unindexed bytes, so every handler persists purely from event.args —
zero contract reads on the hot path. Matches the existing conventions
in meetings.ts and actionVoting.ts: composite ids shaped as
`${contract.toLowerCase()}-${id}`, txHash from event.transaction.hash.

Manifest:

- assembleOrgManifest now accepts openProposals via a backward-compat
  overload — old 6-arg callers still work (the 39 existing test cases
  did not need to change), new callers pass a ProposalRow[] before
  opts. Route handler in src/api/index.ts queries schema.proposal
  WHERE orgId=? AND status=0 (Draft) ordered by submittedAt asc and
  passes the rows through.
- Manifest entries include a path-based permalink (/o/:orgId/p/:id) so
  agents can follow the reference without ad-hoc URL construction.
- openProposals type on OrgManifest replaced the historical `never[]`
  with a concrete shape. `/agents/:orgId.json` now returns real
  proposal rows instead of a hardcoded empty array.

Tests: 5 new cases in test/manifest.spec.ts covering the default-empty
path (backward-compat overload), row → manifest mapping, permalink
shape, caller-ordering preservation, and bigint serialization inside
proposal rows. hollab-indexing vitest 39 → 44.

Lint + typecheck + ponder codegen all clean. Live smoke test still
requires a .ponder reset + DeployLocal re-run (the seed orgs from the
merged WS3 PR were cloned against the pre-refactor MeetingFactory
bytecode and cannot call the new functions) — scheduled for commit 5
with the seed script update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ite methods

Wires the new MeetingFactory entry points (commit 73bbcfd) and the
indexer columns (commit 24c988d) through to the typed GraphQL client
and the agent SDK.

indexing-client:

- PROPOSAL_FIELDS gains orgId, tensionHash, changeType, changeData,
  changeResultId, resolvedBy. OBJECTION_FIELDS gains concernHash and
  resolvedBy. Proposal and Objection types updated to match — new
  fields documented inline.
- LIST_PROPOSALS_BY_ORG (all proposals in an org, submittedAt desc)
  and LIST_OPEN_PROPOSALS_BY_ORG (status=0 only, submittedAt asc)
  added alongside the existing LIST_PROPOSALS_BY_CIRCLE.
- BigInt regression fixes: LIST_PROPOSALS_BY_CIRCLE's circleId and
  LIST_OBJECTIONS_BY_PROPOSAL's proposalId were declared as String
  against bigint columns — same Day 1 episode as GET_ORGANIZATION and
  LIST_ROLES_BY_ORG. Both now BigInt. The new queries start life
  correctly typed.
- client.listProposalsByOrg / listOpenProposalsByOrg methods added.
- MOCK_PROPOSAL and MOCK_OBJECTION fixtures in client.spec.ts updated
  to include the new fields so the 42 existing client tests still
  typecheck against the new shape.
- queries.spec.ts regression set grows to 14 asserts (was 4): all five
  new-or-touched BigInt params plus a PROPOSAL_FIELDS column-coverage
  table test so dropping a new field from the selection gets caught
  the next time someone touches the schema.

agent-sdk GovernanceModule:

- createProposal(meetingFactory, input) — hashes tensionText with
  keccak256 by default, or accepts a pre-computed tensionHash (for
  content stored on IPFS/0G). Returns the extracted proposalId from
  the ProposalCreated event log via decodeEventLog.
- adoptProposal / discardProposal / raiseObjection / resolveObjection
  wrappers, all going through walletClient.writeContract with the
  regenerated meetingFactoryAbi.
- listOpenProposalsByOrg / getProposal read helpers delegate to the
  indexing-client (matches the existing listProposals pattern).
- execute() marked deprecated pointing to the new path.
- Receipt parsing uses viem decodeEventLog so it survives future ABI
  event reordering (the old executeGovernance path assumed topic[3]
  was the resultId, which is fine there but not reusable).

Build + test:
- @hollab-io/indexing-client: 46 → 56 passing.
- @hollab-io/agent-sdk: 4 passing (encoding tests unchanged — the new
  write methods are thin viem wrappers best verified by a live smoke
  test against anvil, which commit 5 covers).
- Full monorepo build + test green across 14 turbo tasks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unblocks the #/o/:orgId/p/:proposalId route that the Day 3 PR
carried over as blocked at the contract layer. Now that the proposal
lifecycle exists (commits 73bbcfd + 24c988d + acd2244), the public
surface can render what agents propose and how the adoption trail
plays out — entirely wallet-less.

New hook file apps/hola-modern/src/hooks/useProposalsFromIndexer.ts:

- useOpenProposalsByOrg — status=0 (Draft), submittedAt asc, limit 50
- useProposalFromIndexer — single fetch by composite
  "<processAddress>-<proposalId>" id
- useObjectionsByProposal — raisedAt asc, scoped by process + proposal

All three go through the typed indexing-client — no wagmi, no RPC.

New view PublicProposalView:

- Status chip (Draft / Adopted / Discarded) with tone colors
- Change type label derived from the uint8 enum
- Tension hash rendered as the content-address (full text resolution
  is a follow-up tied to the IPFS/0G backend decision)
- Proposer chip with the existing 🤖 agent allowlist treatment
- Objections list with per-row raised/resolved state and objector chip
- Raw change payload pre-block (developer surface; a decoded render
  needs the ABI + change-type case analysis, deferred)
- Adopted proposals show their resultId so readers can jump back to
  the role/policy the proposal created

Resolving composite id from (orgId, proposalId):

The route carries orgId + proposalId, but the indexer keys proposals
by `${processAddress}-${proposalId}`. Rather than plumb processAddress
through the URL, the view first reads open proposals for the org (the
org has at most one MeetingFactory clone) to discover the address,
then falls back to a direct getProposal call with the composite id.
Cost: one extra GraphQL request on a cache miss, acceptable for a
public permalink. Cleaner than polluting the URL with an address.

Router:

- useHashRouter gains `{ page: "publicProposal"; orgId; proposalId }`.
- Parser handles `#/o/:orgId/p/:proposalId` with the same
  decodeURIComponent + malformed-fallback pattern as publicRole.
- App.tsx dispatches the new route inside the existing public branch
  (same minimal shell as the other wallet-less views, still above the
  auth gate).
- 3 new tests: parse happy path, malformed fallback to public, and
  round-trip. useHashRouter suite 16 → 19; hola-modern total 23 → 26.

PublicOrgView:

- New "Open proposals" section surfaces between Members and Roles
  when openProposals.length > 0. Each row links to the permalink via
  a new onOpenProposal callback, same click affordance as the role
  cards. Shows proposal id + change-type shorthand + proposer chip
  (🤖 when in allowlist) + tension hash.
- The old TODOs about blocked proposal list + progressive-connect CTA
  are removed — the first is done, the second shipped in Day 3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…opose-tension via lifecycle

Closes out the feat/governance-proposal-lifecycle PR with the data,
examples, and docs the previous four commits were building toward.

SeedDemoOrgs:

- Renames the first demo org from paperclip to lantern. Paperclip
  remains the UX inspiration the sprint doc references (paperclip.ing
  is a real web2 product we're borrowing posture from), but the demo
  org should not literally share the name. Lantern: illuminates,
  warm, shareable, matches the "make orgs legible from the outside"
  story, no brand collision in org-tooling space.
- Appends _seedProposals(lanternId, meetingFactory, agent) that
  creates one proposal in each terminal state on the lantern org:
  - Adopted: "QA Inspector" role — deployer creates + adopts, which
    flows through the shared _applyChange helper and appends role #4
    to lantern's RoleRegistry
  - Draft + unresolved objection: "Storyteller" role + one raised
    objection with a keccak256 concernHash
  - Discarded: "Party Planner" role — created then discarded without
    application
  The three branches give PublicProposalView real data to render from
  a clean anvil stack, including the objection trail for the Draft
  path.
- Resolves the MeetingFactory clone address for the lantern org by
  reading RoleRegistry.governanceProcess() rather than parsing the
  MeetingComponentsDeployed event log — cleaner and doesn't require
  receipt parsing in Solidity.

propose-tension.ts:

- Swaps from the deprecated executeGovernance(CreateRole) shortcut
  to the full createProposal + adoptProposal lifecycle. Agent signs
  createProposal (matching the "agent can propose but not adopt"
  access model); admin (anvil account #0) signs adoptProposal in a
  second wallet client, mirroring the SeedDemoOrgs flow.
- New --draft-only flag stops after createProposal for agents that
  want to demonstrate the propose-without-adopt path. Cheap to wire,
  useful for demos where the adopt step is shown in the UI instead.
- Tension text is hashed to tensionHash via the SDK's default
  keccak256 path; the raw text stays off-chain in the script log.
- Polls the manifest endpoint until the new role appears in
  `roles[]`, then prints both the proposal permalink
  (#/o/:orgId/p/:proposalId) and the org page URL.

Spec doc — specs/05-governance-process.md:

- New "On-chain Commitments Surface" section that documents the
  commitments-vs-coordination stance, maps each of the 5 new events
  to its IDM step in §5.4.5, explains the bytes32 content-address
  discipline for tensionHash / concernHash, lays out the
  authorization table, and states the non-enforcement of the
  "no-adopt-with-unresolved-objections" rule with the explicit
  reasoning (validity is judgment-based, contract can't reliably
  determine it, event log preserves auditability). Lists what is
  deliberately out of scope (process breakdown, integrative election
  internals, objection validity tests, Withdrawn status). Notes the
  executeGovernance deprecation.

Sprint doc — docs/sprint-agent-native-mvp.md:

- Unblocks the #/o/:orgId/p/:proposalId carry-over bullet with a
  pointer to this branch and a summary of what shipped: the
  MeetingFactory surface, the indexer columns, the PublicProposalView,
  the three-status seed, and the spec doc section. executeGovernance
  stays deprecated-but-working for back-compat so the authed
  GovernanceView + SeedDemoOrgs legacy path keep working.

Live smoke test: DeployLocal + SeedDemoOrgs run end-to-end against a
fresh anvil. roleCount goes 3 → 4 (proof the Adopted path flows
through _applyChange), proposalCount 3, objectionCount 1. Role #4 on
lantern is "QA Inspector" with the exact fields from the seed.

Full-monorepo verification:
- forge test: 94 passing
- hollab-indexing vitest: 44 passing
- @hollab-io/indexing-client vitest: 56 passing
- @hollab-io/viem-extension vitest: 29 passing
- @hollab-io/agent-sdk vitest: 4 passing
- hola-modern vitest: 26 passing
- pnpm check-types: clean

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Nothing is deployed anywhere yet, so the back-compat layer added in the
previous five commits was pure overhead. Removing it:

Contracts:
- Delete executeGovernance() entry point from MeetingFactory.sol
- Delete GovernanceExecuted event from IMeetingFactory.sol
- Delete executeGovernance declaration from IMeetingFactory.sol
- Delete test_ExecuteGovernance_StillWorksAfterRefactor (was only there
  to prove the refactor didn't break the legacy path — no legacy path
  now). 20 passing MeetingFactoryProposals tests remain.
- Migrate SeedDemoOrgs._seedOrg baseline role creation and agent
  election to createProposal + adoptProposal. Same signer
  (deployer = member + admin) so both calls land in the same broadcast.
  On-chain result is identical to the previous path but now every role
  mutation leaves a ProposalRecord on-chain.

Indexer:
- Drop the legacy `tension: text` column from ponder.schema.ts (was
  kept empty-string for "back-compat with historical readers" — no
  historical readers exist).
- Remove `tension: ""` assignment from the ProposalCreated handler.
- Simplify assembleOrgManifest signature — remove the ProposalRow[] |
  ManifestOpts overload that was preserving the old 6-arg call shape.
  openProposals is now a required 6th positional param.
- Update all 28 assembleOrgManifest call sites in test/manifest.spec.ts
  to pass the new [] argument before OPTS. 44 passing.

indexing-client:
- Drop `tension: string` field from the Proposal type.
- Drop `tension` from PROPOSAL_FIELDS GraphQL selection.
- Drop `tension` from the MOCK_PROPOSAL test fixture.
- All 56 tests still pass; regression set untouched.

agent-sdk:
- Delete execute(), createRole(), amendRole(), removeRole(),
  electLead() methods from GovernanceModule — all of them were thin
  wrappers around the deleted executeGovernance. createProposal is the
  primitive now; callers that want ergonomic role creation call
  createProposal with encodeCreateRole from `encoding.ts` (the
  propose-tension example already shows the pattern).
- Delete ExecuteGovernanceResult type from types.ts and the re-export
  from index.ts.
- Clean up unused imports in governance.ts.

Frontend:
- Rewrite GovernanceMeetingRoom's buildGovernanceCall as
  buildGovernanceCalls — returns a [createCall, adoptCall] pair per
  action instead of a single executeGovernance call. Caller reads
  proposalCount() once via usePublicClient before iterating and
  predicts sequential ids (startingCount + supportedIndex + 1). Safe
  inside the authed meeting room because the signer is a single wallet
  submitting one batch — no interleaving writers. wallet_sendCalls
  batches both txs atomically; sequential fallback signs them in
  order. Result: the authed meeting room now leaves a ProposalRecord
  on-chain for every governance change it enacts.
- Delete the useExecuteGovernance hook function. Keep encoders
  (encodeCreateRole / encodeAmendRole / encodeRemoveRole /
  encodeElection / *WithRefs) and the ChangeType enum mirror —
  GovernanceMeetingRoom still imports them. File kept at its current
  path to avoid churn on the import paths; header updated.
- Drop the dead `if (proposal.tension)` fallback from
  PublicProposalView — the column no longer exists.

Docs:
- specs/05-governance-process.md "Legacy entry point" section replaced
  with "Only entry point" — states cleanly that createProposal +
  adoptProposal is the only path to structural change.
- docs/sprint-agent-native-mvp.md note updated: executeGovernance is
  deleted entirely, not deprecated.

Verification:
- forge test: 93 passing (was 94; -1 for the deleted legacy regression
  test)
- hollab-indexing vitest: 44 passing
- @hollab-io/indexing-client vitest: 56 passing
- @hollab-io/viem-extension vitest: 29 passing
- @hollab-io/agent-sdk vitest: 4 passing
- hola-modern vitest: 26 passing
- pnpm check-types: clean
- Live smoke test: DeployLocal + SeedDemoOrgs run end-to-end on fresh
  anvil. Verified roleCount=4 (baseline 3 + adopted "QA Inspector"),
  proposalCount=7 (3 baseline creates + 1 election + 3 state-demo
  proposals — every role mutation now leaves a ProposalRecord),
  objectionCount=1, role #4 body matches the seed exactly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Update top-level docs to match the shipped proposal lifecycle, add
CLAUDE.md agent context, and queue two PRDs (public/private tensions,
public objection flow) under docs/prds/. Flag Aztec privacy posture as
v2 in specs/07 and prepend a current-state block to the sprint log.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Skanislav Skanislav merged commit bb12ed5 into dev Apr 15, 2026
6 checks passed
@Skanislav Skanislav deleted the feat/governance-proposal-lifecycle branch April 15, 2026 10:33
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