{"protocol":"clank-doc/1","frameworkVersion":"0.24.0","slug":"approval-quorums","title":"Approval quorum policies","description":"Once quorum storage is enrolled, startup checks the five retained table shapes and singleton state before bootstrap and again inside its transaction. Missing or altered storage returns QUORUM STATE without recreating history or retiring sur","group":{"id":"agents","title":"Agents and generation"},"url":"https://docs.clank.run/docs/approval-quorums","source":"docs/approval-quorums.md","headings":["Define current membership and required roles","Human votes and commit","Restart, migration and rollback"],"tableOfContents":[{"id":"define-current-membership-and-required-roles","title":"Define current membership and required roles","level":2},{"id":"human-votes-and-commit","title":"Human votes and commit","level":2},{"id":"restart-migration-and-rollback","title":"Restart, migration and rollback","level":2}],"markdown":"# Approval quorum policies\n\nOnce quorum storage is enrolled, startup checks the five retained table shapes and singleton state before bootstrap and again inside its transaction. Missing or altered storage returns `QUORUM_STATE` without recreating history or retiring surviving votes. Unsupported protocols still return `QUORUM_PROTOCOL`. This does not certify arbitrary SQL constraints or recovery after all enrollment evidence is erased.\n\nAn application can require several distinct human approvals before a reviewed action\nchanges its data. Quorums extend the existing [reviewed-action workflow](governance.md),\nincluding record-bound previews, its native browser inbox and exact committed receipts.\nAn action without `approvalQuorum` keeps its established single-approver behavior.\n\nThe initial implementation uses one native SQLite application store. Authentication,\nmembership, security policy, votes and the application mutation must share that store.\nIt does not provide remote consensus or accept votes from external identity transports.\nThere are no additional NPM dependencies.\n\n## Define current membership and required roles\n\nPass the database schema to `defineReviewedAction` so membership reads, preview and\nexecution retain their consumer types. This example assumes that trusted application\nadministration maintains a tenant-scoped membership table with a monotonic security\npolicy version and a unique membership incarnation. Its public CRUD API must not let\nmembers grant themselves roles or change those authority fields.\n\n```ts\nimport {defineReviewedAction} from '@clank.run/framework/reviewed-actions';\nimport {defineAuth,defineBackend,defineDatabase,defineTable,openBackend,s} from '@clank.run/framework';\n\nconst schema=defineDatabase({\n  tasks:defineTable({done:s.boolean()}).owned(),\n  members:defineTable({userId:s.string(),scope:s.string(),role:s.string(),\n    incarnation:s.string(),policyVersion:s.string()}),\n});\nconst scope='reviewed_workspace_01'; // Trusted application binding, never a browser claim.\nconst finish=defineReviewedAction(schema,{\n  revision:'finish-v2',title:'Finish a reviewed task',\n  args:s.object({id:s.id('tasks')}),previewDependencies:'records',\n  authorize:({auth})=>Boolean(auth.user),\n  preview:({db},{id})=>{\n    const task=db.table('tasks').get(id);\n    if(!task)throw new Error('Task not found.');\n    return {id,before:task.done};\n  },\n  authorizeApproval:({db,auth})=>{\n    const member=db.table('members').query().where('scope',scope)\n      .where('userId',auth.user!.id).first();\n    return Boolean(member && ['reviewer','operator'].includes(member.role));\n  },\n  approvalQuorum:{revision:'review-policy-v1',minimum:2,\n    requiredRoles:['reviewer','operator'],separateRequester:true,voteTtlMs:300000,\n    membership:({db,auth})=>{\n      const member=db.table('members').query().where('scope',scope)\n        .where('userId',auth.user!.id).first();\n      return member?{scope,role:member.role,\n        version:member.incarnation+':'+member._version,\n        policyVersion:member.policyVersion}:null;\n    }},\n  execute:({db},{id},preview)=>{\n    db.table('tasks').patch(id,{done:true});return {id,before:preview.before};\n  },\n});\nconst definition=defineBackend({schema,auth:defineAuth()}).functions(()=>({}));\nconst backend=await openBackend(definition,{\n  path:'app.sqlite',reviewedActions:{actions:{finish}},\n});\n```\n\nThe requester needs current membership in the same scope; they need not have an\napprover role. Keep the scope and security-policy version consistent across all\nmembers of that policy. Never reuse a removed membership incarnation, reduce its\npolicy version, derive roles from `AuthState` JSON, or query an external service from\nthe resolver. Identifiers and versions are bounded plain tokens up to 200 characters.\n\nBoth `membership` and `authorizeApproval` run synchronously with current native\nauthentication and scoped read-only database access. Membership must actually read\nthrough `context.db`, retaining 1–128 generated-table dependencies. A Promise or\nuntracked constant cannot establish approval authority. A table query conservatively\ntracks the visible table, so membership changes can invalidate other recorded votes.\nUse bounded point reads when your membership schema supports them.\n\nApplications bound through `openBackend.organizationSecurity` also apply their\ncurrent native [organization security policy](organization-security-policies.md) to\nthe requester and every approver. The membership resolver must return the current\nsecurity-policy version from its trusted native adapter. Policy tightening invalidates\nidentity dependencies and can require a new preview or new authentication. A direct\n`openReviewedActions` integration must bind native auth to this database and configure\n`authorizeCaller` to enforce its current organization-security controller.\n\n## Human votes and commit\n\nThe agent or application requests the usual `review.plan.ACTION` plan. Authorized\nhumans inspect its preview at `/__clank/approvals`, then submit native approve/deny\nforms with their current session and CSRF proof. The browser inbox shows recorded\nvote progress, required roles and expiry. It uses ordinary keyboard-accessible HTML\ncontrols and does not require JavaScript. Its displayed recorded count is historical\nprogress; commit always checks current authority again.\n\nQuorum policies require 2–8 distinct humans. Each person supplies one current role,\nand each required role must be represented; a second session for the same person\ndoes not create a second vote. `requiredRoles` is a unique list no longer than\n`minimum`. Requester/approver separation defaults to enabled and applies to denial\nas well. Set `separateRequester:false` only when your reviewed business policy allows\nself-voting. Machine principals, OAuth grants and sessionless identities cannot vote.\nThey do not inherit a human session through a serialized object.\n\nRepeating an unchanged valid vote for the same person and plan returns the current\nplan without adding a vote or event. That covers a lost response. A stale or expired\nvote requires another current human review; it may replace that person's retained\nvote but never increases the distinct count. A denial is terminal. A plan retains at\nmost eight actor entries, including expired entries; request a new preview if that\ncapacity is reached. Vote lifetime defaults to five minutes, accepts one second to\none day, and never outlives the plan's own deadline.\n\nThe requester calls the established `review.commit` tool or `backend.reviewedActions.commit`.\nThe native transaction verifies the current action/policy revision, requester access,\npreview dependencies, scope, all qualifying native sessions, role requirements,\nmembership incarnations, security-policy versions and expiry. Revoked or expired\nsessions and removed/rejoined members cannot contribute. Relevant generated-table\nchanges and journal-retention gaps conservatively invalidate votes, including a role\nchange followed by restoration to its previous value.\n\nSynchronous execution receives the existing recording writer. The transaction checks\napproval authority again after execution and after storing the receipt/event, so an\nexecution-time membership change cannot authorize its own commit. Transaction-local\ndependency checks see pending native writes before the change journal flushes. An\naction that changes an approval dependency therefore fails conservatively; redesign\nthat operation as a separately reviewed action. All callbacks must avoid external\neffects. Use the existing transactional job/outbox mechanism for later external work.\n\nThe application write, vote consumption, exact result receipt and audit event commit\ntogether. Ignored or altered native acknowledgment writes cause rollback. Retrying a\nconsumed plan returns its exact receipt after checking the requester's current access;\nit never executes again. The representative `approvedBy` field is retained for\ncompatibility and does not replace the required distinct quorum.\n\n## Restart, migration and rollback\n\nOpening the controller retires every previously live quorum vote. Plans, historical\nvotes, events and exact committed receipts remain durable; pending quorum plans need\nnew current human votes after restart. This is conservative authority retirement,\nincluding after a crash or failed expiry transaction. A running controller's observed\nclock never decreases, even when a denied transaction rolls back. A wall-clock rollback\nand subsequent reopening cannot revive a prior vote. Single-approver actions keep\ntheir existing restart behavior. The same retirement applies when another controller\nopens against this store; stage process restarts before asking people to vote again.\n\nMigration adds nullable quorum metadata to existing plans plus bounded protocol/state\nand vote tables. The application table names `reviewed_votes` and\n`reviewed_quorum_state` are reserved for this native metadata. Existing plans are\nnot silently upgraded to quorum. Unknown retained\nprotocols fail closed without deleting evidence. Change the quorum's `revision` whenever\nits roles, resolver semantics or separation rules change. Changes to the action still\nrequire its existing action revision. Pending plans cannot be reinterpreted after\ndisabling or replacing a quorum configuration.\n\nThe legacy `approved_session` column remains null for quorum approvals, so an older\nsingle-approver binary cannot consume them as one person's approval. A native SQLite\ntrigger rejects non-null legacy session updates and removal/replacement of retained\nquorum metadata, including attempts by an older decision path. Before downgrade,\nquiesce reviewed operations and close pending quorum plans through the current binary.\nRetain native historical evidence and committed receipts; do not manually manufacture\nlegacy session fields. Terminal-plan cleanup removes its votes under the existing\nbounded reviewed-action retention policy.\n\nQuorum receipts advertise `compensationAvailable:false`, and direct compensation\nreturns `QUORUM_COMPENSATION_REVIEW`. Define a compensation as another reviewed quorum\naction with its own current preview and votes; it cannot inherit the original votes.\nExisting non-quorum compensation remains unchanged.\n\nNative authentication, SQLite mutation/replay, revocation, policy-tightening and\nrestart checks are separate from browser and production acceptance. Native fixtures\nand rendered inbox HTML do not certify physical authenticators, real keyboard/focus\ninteraction or production host behavior. Check the current implementation ledger for\nthe feature's remaining acceptance evidence.\n"}