Skip to content

feat: company standing gate (generic run-blocking primitive) - #256

Open
stubbi wants to merge 38 commits into
mainfrom
feat/company-standing-gate
Open

feat: company standing gate (generic run-blocking primitive)#256
stubbi wants to merge 38 commits into
mainfrom
feat/company-standing-gate

Conversation

@stubbi

@stubbi stubbi commented Jul 18, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work.
  • Agents run continuously against companies via the heartbeat/scheduling loop, and core already hard-stops runs on budget exhaustion (getInvocationBlock), proving there's a precedent for a company-level "stop starting new work" primitive.
  • What's missing is a generic version of that primitive that isn't wired to money: no plugin — billing, compliance, quota, or otherwise — has a way to tell core "this company may not start new work" without core knowing anything about why.
  • Without it, the billing plugin (and any future governance plugin) would have to invent its own ad hoc enforcement, duplicated per plugin, with no shared cleanup guarantee when a governance plugin is removed — a real risk of stranding companies in a blocked state forever.
  • This pull request adds company_standing, a row-per-(company, plugin) table with an active | grace | blocked severity, a company.standing.write capability, host-service methods (setStanding / clearStanding), enforcement at run enqueue + claim + retry, and UI banners/badges fed by the PR-1 capabilities payload.
  • The benefit is a single, generic, fail-safe run-blocking hook any governance-shaped plugin can use, with a hard guarantee that removing the plugin (uninstall/instance-disable/company-disable) clears its holds so no company is ever stranded by a plugin bug.

Linked Issues or Issue Description

Fixes #259

No pre-existing public issue tracks this; describing in-PR per the feature-request template.

Problem or motivation
There is no generic mechanism for a plugin to stop a company from starting new agent work. Billing needs exactly this (block runs on a lapsed subscription, without core knowing about money), and future compliance/quota plugins will need the same shape. Building it ad hoc per plugin risks inconsistent enforcement points and, worse, no shared cleanup guarantee — a buggy or removed governance plugin could leave a company permanently blocked.

Proposed solution
New core table company_standing (PK company_id + plugin_id, status: active|grace|blocked, reason, message, action_url, updated_at) — row-per-plugin so plugins cannot clobber each other's holds. Effective standing per company = most severe row; no rows ⇒ active (fail-safe default). New capability company.standing.write and host-service methods ctx.companies.setStanding(companyId, {...}) / clearStanding(companyId), always scoped to the calling plugin. Enforcement is one check reused at every run-admission path: the enqueue gate, the claim gate, and the scheduled-retry gate — all consulting effective standing alongside the existing budget hard-stop. grace never blocks (UI-only warning); only persisted blocked rows stop work. Cleanup is automatic: uninstalling a plugin, instance-disabling it, or a company disabling it deletes that plugin's standing rows. UI: banners and company-switcher badges ride the PR-1 capabilities.companyStandings payload — no bespoke fetch/banner component.

Alternatives considered
A single active/blocked boolean per company (no grace, no per-plugin rows) was rejected — it would let a second plugin's clear accidentally unblock a company another plugin meant to keep blocked, and gives no room for a "you're about to be blocked" warning state that billing's grace period needs. Enforcing only at enqueue (skipping claim/retry) was rejected because a run already claimed before a company goes blocked could otherwise complete a retry loop indefinitely; enforcing at all three admission points closes that gap.

Roadmap alignment
Checked ROADMAP.md — generic core primitive, upstreamable, not overlapping planned core work. Full design at docs/superpowers/specs/2026-07-18-settings-visibility-and-plugin-enablement-design.md §5. EffectiveStanding type is reused from PR-1's capabilities payload shape.

What Changed

  • New company_standing migration (fk company_id, fk plugin_id, status enum, reason, message, action_url nullable, updated_at); effective standing = most-severe-row merge, deterministic tiebreak added (ORDER BY on severity ties — closed a nondeterminism found in review).
  • company.standing.write capability (flagged sensitive on the plugin install screen).
  • Host services setStanding / clearStanding, always scoped to the calling plugin; cleanup wired into plugin uninstall, instance-disable, and company-disable paths (registry inline delete, intentionally not shared with the general clear helper — see Risks).
  • Enforcement at three admission points: run enqueue, claimQueuedRun, and evaluateScheduledRetryGate — all now check effective standing alongside the existing budget block; typed error company_blocked carries message + actionUrl.
  • Reads/settings/company pages remain fully accessible under blocked — only new-run admission is affected.
  • UI: Layout-level standing banner (grace → warning + action link, blocked → error banner) and company-switcher badges, both fed by capabilities.companyStandings (PR-1 payload).
  • actionUrl validation hardened (rejects non-relative/unsafe URLs before persisting).

Verification

  • cd server && pnpm typecheck — 0 errors (branch was sanctioned red mid-stack for one task pending a follow-up fix; green again before merge).
  • Severity-merge unit tests, including the deterministic-tiebreak case for simultaneous blocked rows from two plugins.
  • Run-start gate tests: blocked ⇒ typed company_blocked error at all three admission points (enqueue, claim, retry); grace ⇒ runs proceed unaffected.
  • Cleanup tests: uninstall / instance-disable / company-disable each delete exactly that plugin's standing rows and no others.
  • Banner + switcher-badge rendering tests across active/grace/blocked.
  • 18 commits on feat/company-standing-gate, base feat/settings-surface-policy; final whole-branch review returned "with fixes" (claim/retry gates initially missed the standing check, caught in review) — fix wave landed and re-verified.

Risks

  • Base branch: stacked on feat/settings-surface-policy (PR-1), and is a sibling of feat/company-plugin-enablement (PR-2) — both branch from the same PR-1 head. Diff against main will include PR-1 until it merges; review the PR-1-vs-PR-3 diff range.
  • Fail-safe by construction: unknown/unwritten standing is always active — a crashed or buggy governance plugin can never lock a company out; only an explicit, successfully-persisted blocked row stops work. This was a deliberate design constraint, not an incidental property.
  • The most significant finding in final review was that the claim and scheduled-retry admission gates initially rechecked budget but not standing — closed in the fix wave; reviewers should specifically re-verify claimQueuedRun (~line 10334) and evaluateScheduledRetryGate (~line 8841) both consult effective standing.
  • Known follow-up, not blocking: plugin-registry.ts inlines the scoped standing-delete instead of calling the shared companyStandingService.clearStanding (a brief-mandated duplication to avoid a cross-module cycle) — tracked as a DRY refactor candidate, not a correctness issue.
  • Upstream note for maintainers: the company_standing migration will need renumbering against upstream's drizzle migration chain at cherry-pick/upstream time (fork's snapshot chain is currently forked from upstream's).

Model Used

Claude (Anthropic), Sonnet 5 (model id claude-sonnet-5) driving Claude Code's subagent-driven SDD workflow — spec-driven task briefs, extended multi-step tool use, and an independent code-review pass per task, plus a dedicated whole-branch final review before merge.

Screenshots

Standing banner — grace vs blocked

Standing banner (grace — warning + action link)

Standing banner (blocked — error banner)

Company switcher — standing badges

Company switcher standing badges

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have not referenced internal/instance-local Paperclip issues or links (only public GitHub #NNN / github.com/paperclipai/paperclip URLs)
  • My branch name describes the change (e.g. docs/..., fix/...) and contains no internal Paperclip ticket id or instance-derived details
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

stubbi and others added 30 commits July 18, 2026 10:49
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ings validator barrel exports

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /instance/settings, /general, /experimental move from
assertBoardOrgAccess to assertCanManageInstanceSettings. The UI migrates
to /cli-auth/me capabilities.features in the same PR.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yStandings) on GET /cli-auth/me

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR-1 made GET /api/instance/settings/experimental instance-admin-only,
which broke `paperclip cloud push` for non-admin board users: they'd
get 403 "Instance admin access required" instead of the intended
"Cloud sync is disabled" message. Switch the cloud-sync gate to read
capabilities.features.enableCloudSync from GET /api/cli-auth/me, which
is available to any authenticated board user and derives the same
flag server-side.

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

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

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

Adds capabilities: BoardCapabilities to CurrentBoardAccess (from
GET /cli-auth/me), and useFeatures/useBoardCapabilities hooks sharing
a single react-query cache entry (queryKeys.access.currentBoardAccess).
useFeatures selects capabilities.features for the settings-surface
policy migration off instance-admin-only /instance/settings* reads.

Adds queryKeys.instance.visibilitySettings (used in Task 14) and the
buildCurrentBoardAccess test fixture builder. Updates
IssueDetail.test.tsx call sites that construct CurrentBoardAccess
literals directly, since capabilities is now a required field.

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

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

Gate CompanySettingsSidebar and CompanySettingsNav company entries on
useBoardCapabilities().exposedSurfaces, restrict the Instance settings
section/tabs to isInstanceAdmin, and move the per-user Profile entry out
of the instance-only block. Add SurfaceGuard to redirect
company/settings/{members,invites,secrets} navigation misses to
/company/settings when the loaded capabilities say the surface is
hidden (degrades closed while capabilities are loading/errored, since
the server remains authoritative). Rewrite CompanySettingsSidebar.test.tsx
onto the Task 11/12 access mock and add member/admin/degrade-closed/
plugin-override cases; extend CompanySettingsNav.test.tsx with a
surface-filtering case; add SurfaceGuard.test.tsx; adjust Layout.test.tsx's
mobile-selector test to mock instance-admin + cloud-sync access so it still
exercises the full tab set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add error handling to CompanySurfaceVisibilityCard following the established pattern from InstanceGeneralSettings. Failed PATCH operations now display an error banner with the error message, rather than silently reverting the button.

- Add actionError state to track failed saves
- Set error message in onError handler
- Clear error on successful save
- Display destructive error banner when actionError is set
- Add failing test that asserts error banner appears on save failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t, test names)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd company.standing.write capability

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n company.standing.write

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR-1's capabilities builder shipped a hardcoded `{}` stub for
companyStandings pending PR-3. Wire it to the real
companyStandingService.getEffectiveStandings(accessSnapshot.companyIds)
so the CLI sees each requested company's effective standing (every
requested company is seeded, active by default, blocked/grace when a
plugin has written a row).

Also add the companyStandingService export to the services/index.js
mocks in invite-accept-existing-member, invite-list-route, and
openclaw-invite-prompt-route route tests, since accessRoutes() now
constructs the service unconditionally and those suites were
otherwise-passing before this change.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
stubbi and others added 8 commits July 18, 2026 14:43
Implement hardening for actionUrl handling in company standing:
- Server-side validation: reject javascript:/data: URLs, allow app-relative
  paths (/) and http/https URLs only
- UI: app-relative URLs keep same-tab navigation; absolute URLs render with
  target="_blank" rel="noreferrer" per repo pattern (ExternalObjectPill)
- Tests: validate rejected/accepted URL schemes; verify link attributes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity routes in OpenAPI spec

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend service mocks in cli-auth-routes, bootstrap-claim-routes,
company-user-directory-route, invite-create-email, and invite-create-route
test suites with companyStandingService mock (getEffectiveStandings resolving {}).

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

Standing enforcement was admission-only (heartbeat.wakeup): a blocked
company could still have its queued runs claimed via resumeQueuedRuns
or its scheduled retries promoted, since claimQueuedRun and
evaluateScheduledRetryGate only checked budget. Add the same
companyStandingService(db).getEffectiveStanding(companyId) check
beside each site's existing budget check, mirroring how each site
already disposes of a budget-blocked run/gate (cancelRunInternal /
gate refusal). `grace` never blocks, matching the admission gate.

Covered by new TDD suites: claimQueuedRun via resumeQueuedRuns
(blocked disposes with the standing reason, grace claims normally)
and evaluateScheduledRetryGate via scheduleBoundedRetry (blocked
refuses with company_standing_blocked, grace schedules normally).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- getEffectiveStandings: order the row query by updatedAt desc, then
  pluginId, so two rows tied on severity resolve deterministically to
  the newest row's reason instead of depending on DB scan order.
- setStanding: the actionUrl-scheme catch checked
  err.message.includes("Invalid standing"), but the thrown message is
  "Invalid actionUrl scheme...", so that branch never matched. Split
  the URL-parse failure from the scheme-check failure so the
  invalid-scheme badRequest propagates once, with no dead re-create.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@stubbi
stubbi changed the base branch from feat/settings-surface-policy to main July 18, 2026 18:44
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.

No generic primitive for governance plugins to block company work

1 participant