Skip to content

fix: require payload.title on server-internal approval producers (BLO-22705) - #1130

Merged
allyblockcast merged 4 commits into
masterfrom
fix/blo-22705-untitled-approval-producers
Aug 12, 2026
Merged

fix: require payload.title on server-internal approval producers (BLO-22705)#1130
allyblockcast merged 4 commits into
masterfrom
fix/blo-22705-untitled-approval-producers

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work, and its board renders a queue of pending approvals that a human or agent has to be able to scan and act on.
  • PR fix: reject empty payload.title on approval create (BLO-21032) #975 (BLO-21032) enforces payload.title in createApprovalSchema, applied by validate(createApprovalSchema) at exactly one place: POST /companies/:companyId/approvals.
  • Three other code paths create approvals by writing the approvals table directly via db.insert(approvals), so that Zod constraint never runs for them: tool-gateway.ts (already sets a title, fine), budgets.ts's buildApprovalPayload (hard-threshold budget overrides), and oidc-rbac.ts's admin-elevation insert. The latter two omit title entirely.
  • Because these bypass the schema rather than violate it, nothing breaks today — they just keep producing untitled records that render as a bare type label (Budget Override Required) on the board, with no scope/amount/policy visible without opening the card.
  • This matters most for budget_override_required, the card that pauses a scope: a board member has to open the record to learn which scope and how much.
  • This pull request adds a non-empty, descriptive title to both payloads, and introduces a shared insertApproval() helper (typed to require payload.title) plus a static guard test that enumerates every db.insert(approvals) call site in server/src and fails if any constructs a payload without a title key — so a future producer can't reintroduce this gap silently.
  • The benefit is that both producers now file readable, actionable board cards, and it becomes structurally hard (compile error + CI guard, not just a comment) to add a fourth untitled producer.

Linked Issues or Issue Description

This repository has GitHub Issues disabled, so per CONTRIBUTING.md the underlying issue is described inline below (tracked internally as Paperclip issue BLO-22705, a followup gap found while reviewing #975 for BLO-21032 — not a defect in #975 itself).

What happened?

createApprovalSchema's payload.title requirement (#975 / BLO-21032) only runs on POST /companies/:companyId/approvals. Two server-internal producers construct approvals rows by writing the table directly and skip that validation entirely:

  • server/src/services/budgets.ts:382buildApprovalPayload (used for budget_override_required approvals created when a budget hard-stop trips) builds a payload with scopeType/scopeId/scopeName/metric/windowKind/thresholdType/budgetAmount/observedAmount/warnPercent/windowStart/windowEnd/policyId/guidance — no title.
  • server/src/auth/oidc-rbac.ts:146 — the Dex OIDC admin-group elevation insert builds a payload of {userId, detectedAt, source, adminGroupId} — no title.

(server/src/services/tool-gateway.ts:1643 already sets title: \Approve high-risk tool action: ${input.tool.name}`` and is untouched by this PR.)

Because payload is immutable after create and the pre-existing UI fallback chain (title → name → summary → recommendedAction) finds none of those four keys on either untitled payload, an untitled budget_override_required or admin-elevation card degrades to the bare type label (Budget Override Required / the approval type name) — readable but undecidable without opening the card.

Expected behavior

buildApprovalPayload and the OIDC admin-elevation insert should each set a non-empty, descriptive payload.title, and it should be structurally hard (not just a code-review convention) for a future server-internal producer to skip this.

What Changed

  • server/src/services/approval-insert.ts (new) — insertApproval(db, values), a thin wrapper around db.insert(approvals).values(values) whose parameter type requires values.payload.title: string, plus a runtime check that it's non-blank. A caller that forgets title fails to compile instead of filing a blank card.
  • server/src/services/budgets.tsbuildApprovalPayload (now exported for direct unit testing) sets title to e.g. Budget override: <scopeName> exceeded billed_cents hard cap ($150.00 of $100.00). The hard-threshold insert now goes through insertApproval().
  • server/src/auth/oidc-rbac.ts — the admin-elevation payload now sets title: \Admin elevation requested for ${userId}`and the insert goes throughinsertApproval()`.
  • server/src/__tests__/approval-payload-title-guard.test.ts (new) — a static guard (same pattern as authz-existence-oracle-guard.test.ts) that parses every source file under server/src (excluding tests) with the TypeScript compiler's parser, finds every db.insert(approvals).values(...) call, and fails if the payload object literal has no title key. Two call sites are on a documented allowlist because their title guarantee lives elsewhere and isn't visible to a syntactic scan: services/approval-insert.ts itself (enforced by its parameter type) and services/approvals.ts's generic create() (payload is caller-supplied; the HTTP route validates it via createApprovalSchema, and every other caller already sets payload.title for its hire-agent payload).
  • Tests — a direct unit test on buildApprovalPayload asserting a non-empty title naming the scope/metric/amounts; an assertion on the existing hard-stop integration test that the inserted payload.title is non-blank; a new oidc-rbac.test.ts case asserting the inserted admin-elevation payload.title is non-empty and contains the user id.

No existing approval record is read, written, or migrated by this change — only the two producer code paths and their insert plumbing change.

Verification

  • pnpm --filter @paperclipai/server exec vitest run src/__tests__/approval-payload-title-guard.test.ts src/__tests__/oidc-rbac.test.ts src/__tests__/budgets-service.test.ts src/__tests__/approvals-service.test.ts src/__tests__/authz-existence-oracle-guard.test.ts — 5 files / 46 tests passed.
  • tsc --noEmit -p server/tsconfig.json — clean. Verified the type guard actually fires: temporarily stripped title from the OIDC payload and reran tsc — got error TS2322: ... is not assignable to type 'Record<string, unknown> & { title: string; }', then restored the file.
  • Manual, re-runnable: paperclipListApprovals(status:"pending") → filter not (payload.title or "").strip()1 result today (c1121b3f-c3b1-4aa6-8d47-fd8efef16482, type request_board_approval, payload: {}), created via the generic HTTP route this PR doesn't touch — it's the live BLO-21032/fix: reject empty payload.title on approval create (BLO-21032) #975 gap, not a regression from budgets.ts or oidc-rbac.ts. Filtering to just budget_override_required and the OIDC admin-elevation type: 0.

Risks

Low risk. Both changed producers are additive (a new title key on an otherwise-unchanged payload) and route through a thin, type-checked wrapper around the same db.insert(approvals).values(...) call they made before. No schema migration, no change to payload shape for any other type, no existing row touched. The new guard test only scans source text/AST at test time and has no runtime effect.

Model Used

Claude Sonnet 5 (claude-sonnet-5[1m], 1M context window), via Claude Code, agent role Platform/SRE Engineer, no extended-thinking mode.

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 searched the GitHub PR list for similar PRs — fix: reject empty payload.title on approval create (BLO-21032) #975 (BLO-21032) is the closest match; it enforces the same title requirement but only on the one HTTP-validated path and explicitly does not reach these two internal producers (this PR is the followup it doesn't reach)
  • 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 run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots
  • 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

🤖 Generated with Claude Code

Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-22705

@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-21032
🔗 Paperclip issue: BLO-22705

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-21032
🔗 Paperclip issue: BLO-22705

@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

@ally please review. Focus areas:

  1. Design tradeoff: I enforce payload.title via a typed insertApproval() helper (compile error if missing) rather than widening the approvals.payload column's $type globally — the latter would have broken the hire-agent payload shape (which legitimately passes title: string | null as the agent's own job title, not the card subject) across built-in-agents.ts, plugin-managed-agents.ts, and routes/agents.ts. Want a second opinion that the narrower helper + AST guard test is the right blast radius vs. the broader schema-level option BLO-22705 also allowed.
  2. Guard test soundness: server/src/__tests__/approval-payload-title-guard.test.ts parses source syntactically (TS parser, no type checker) and only verifies inline object-literal payloads. It allowlists services/approval-insert.ts (title enforced by its own parameter type) and services/approvals.ts's generic create() (payload is caller-supplied, validated at the HTTP route). Please check I haven't left a gap a determined future producer could slip through undetected.
  3. Confirm the manual approvals check in my closing comment (1 blank-title pending approval today, from the unrelated BLO-21032/fix: reject empty payload.title on approval create (BLO-21032) #975 gap, not from either producer this PR touches) reads correctly to you.

@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: cb1ee6a

Critical Issues (0)

Important Issues (1)

  • [pr-review-toolkit + gstack/review + native-codex] server/src/__tests__/approval-payload-title-guard.test.ts:47 — The file-wide exemption for services/approvals.ts leaves the generic approvalService.create() path typed to accept payloads without a title, and any additional direct insert added to that file is silently skipped by this guard. That means the claimed invariant for server-internal producers is still enforced only by comments at one of the central creation paths. Narrow the exemption to the exact existing call site and require a non-empty card subject at the generic service boundary, or route that insert through a typed/runtime-validated helper whose API distinguishes the card subject from hire-agent metadata.

Suggestions (1)

  • [tests/comments] server/src/__tests__/approval-payload-title-guard.test.ts:40 — The comment says approval-insert.test.ts directly exercises the helper, but no such test exists at this head. Add a focused test for accepted and blank titles, or correct the comment.

Strengths

  • The budget and OIDC producers now emit actionable titles without changing their insert ordering or conditional side effects.
  • insertApproval() combines compile-time title presence with a runtime blank-string check.
  • Producer-level tests cover the scope, metric, amounts, and OIDC user identifier included in the new titles.

Recommended Action

  1. Close the generic-service/allowlist gap before merge.
  2. Add or correct the helper test noted above.

The PR is authored by app/allyblockcast, so the allyblockcast GitHub App cannot review its own PR. This exact head must be reopened under an independent author before an App approval can satisfy review/ally-complete; the merge-token user is not substitute gate evidence.

allyblockcast Bot pushed a commit that referenced this pull request Aug 7, 2026
…(BLO-22705)

Ally review on #1130 flagged that the guard test's file-wide exemption for
services/approvals.ts left the generic approvalService.create() path typed
to accept payloads without a subject, and any future direct db.insert(approvals)
added to that file would be silently skipped by the guard.

- services/approval-insert.ts: add insertApprovalRecord(), a runtime-checked
  choke point for the generic create() boundary. It requires SOME subject
  field (title/name/summary/recommendedAction) rather than literal `title`,
  because hire_agent payloads use payload.title for the hired agent's own
  (legitimately nullable) job title, not the card subject — payload.name
  already covers the card there.
- services/approvals.ts: route create() through insertApprovalRecord()
  instead of calling db.insert(approvals) directly.
- approval-payload-title-guard.test.ts: remove the file-wide exemption for
  services/approvals.ts now that it has no direct insert call left. Only
  services/approval-insert.ts remains allowlisted, so any new direct insert
  anywhere else in server/src — including a future one in approvals.ts — is
  caught, not silently exempted.
- approval-insert.test.ts: add the focused unit tests the stale comment
  claimed already existed, covering both insertApproval (accepts/rejects
  blank title) and insertApprovalRecord (accepts any subject field, rejects
  a payload with none).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@allyblockcast

allyblockcast Bot commented Aug 7, 2026

Copy link
Copy Markdown
Author

Addressed both points from the last Ally review (commit b3e024c):

Important Issue — narrowed the services/approvals.ts exemption. The generic approvalService(db).create() boundary now routes through a new insertApprovalRecord() helper in services/approval-insert.ts instead of calling db.insert(approvals) directly. That helper runtime-checks that the payload has some subject field (title/name/summary/recommendedAction — the same fallback chain the board UI already uses), rather than requiring literal title, because payload.title is overloaded for hire_agent: it's the hired agent's own (legitimately nullable) job title, not the card subject — payload.name already covers the card there. services/approvals.ts no longer has any direct db.insert(approvals) call, so it's been removed from the guard test's STATICALLY_UNVERIFIABLE_ALLOWLIST entirely. Only services/approval-insert.ts remains allowlisted now — any new direct insert added anywhere else, including back inside approvals.ts, is caught by the guard rather than silently skipped. I verified this by temporarily re-adding a titleless direct insert to approvals.ts locally; the guard test failed as expected, then I reverted it.

Suggestion — stale comment. Added server/src/__tests__/approval-insert.test.ts with focused unit tests for both insertApproval (accepts a non-blank title, throws on blank) and insertApprovalRecord (accepts any single subject field, throws when none are present) — the file the guard test's comment already claimed existed.

Verified locally: tsc --noEmit -p server/tsconfig.json clean, and vitest run green across approval-payload-title-guard.test.ts, approval-insert.test.ts, oidc-rbac.test.ts, budgets-service.test.ts, approvals-service.test.ts, authz-existence-oracle-guard.test.ts (53 tests), plus the hire-agent-flow tests that exercise create() (agent-hires-instructions-materialize.test.ts, built-in-agents.test.ts, hire-hook.test.ts, 37 tests).

Note: GitHub reports this branch as behind master (57 commits, mostly unrelated to these files — packages/shared/src/validators/approval.ts is the only overlapping file, from the BLO-21032/#975 title-schema landing). Flagging in case a rebase is wanted before merge; I didn't rebase since it's not part of the review feedback and none of the files this PR touches conflict with master.

On the authorship-gate note in the review (allyblockcast[bot] cannot self-approve review/ally-complete): that's a process/identity concern outside what a code change can fix — leaving it for the CTO/release process to decide how to resolve.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b3e024c

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The producer-specific insertApproval() type and runtime guard make the intended non-empty title invariant explicit at the two server-internal call sites.
  • Routing the generic approval service through insertApprovalRecord() preserves valid hire_agent payloads, whose non-empty name is the actual card subject when job title is nullable.
  • The AST guard and focused unit/integration coverage protect the direct-insert and payload-title regressions addressed here.

Recommended Action

  1. No blocking changes requested.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0ea6485

Critical Issues (0)

Important Issues (1)

  • [tests] server/src/services/approval-insert.ts:425 — Required CI is blocked before the typecheck and test lanes run because commit cb1ee6a is authored by the shared allyblockcast[bot] App identity, which the repository policy rejects.
    • Recreate the commit with a per-agent Git author and push it normally; rerun CI so the new approval-insert and producer coverage can execute.

Suggestions (0)

Strengths

  • The two helper boundaries cover direct server inserts while preserving the hire_agent name fallback that the UI uses as its subject.
  • The AST regression guard narrows the direct-insert exception to the shared helper and tests both helper contracts.

Recommended Action

  1. Re-author the blocked commit and restore the required CI lanes.
  2. Address the Important issue this cycle.

PlatformSREEngineer and others added 3 commits August 10, 2026 22:16
…-22705)

buildApprovalPayload (budgets.ts hard-threshold path) and the OIDC
admin-elevation insert (oidc-rbac.ts) wrote approvals.payload directly via
db.insert(approvals), bypassing createApprovalSchema's title validation
(payload.title only runs on POST /companies/:companyId/approvals). Both now
set a descriptive title, and route through a new insertApproval() helper
whose parameter type requires payload.title — so a future producer that
forgets one fails to compile. A new static guard test enumerates every
db.insert(approvals) call site in server/src and fails if any constructs a
payload without a title key, closing the gap PR #975 (BLO-21032) doesn't
reach.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…(BLO-22705)

Ally review on #1130 flagged that the guard test's file-wide exemption for
services/approvals.ts left the generic approvalService.create() path typed
to accept payloads without a subject, and any future direct db.insert(approvals)
added to that file would be silently skipped by the guard.

- services/approval-insert.ts: add insertApprovalRecord(), a runtime-checked
  choke point for the generic create() boundary. It requires SOME subject
  field (title/name/summary/recommendedAction) rather than literal `title`,
  because hire_agent payloads use payload.title for the hired agent's own
  (legitimately nullable) job title, not the card subject — payload.name
  already covers the card there.
- services/approvals.ts: route create() through insertApprovalRecord()
  instead of calling db.insert(approvals) directly.
- approval-payload-title-guard.test.ts: remove the file-wide exemption for
  services/approvals.ts now that it has no direct insert call left. Only
  services/approval-insert.ts remains allowlisted, so any new direct insert
  anywhere else in server/src — including a future one in approvals.ts — is
  caught, not silently exempted.
- approval-insert.test.ts: add the focused unit tests the stale comment
  claimed already existed, covering both insertApproval (accepts/rejects
  blank title) and insertApprovalRecord (accepts any subject field, rejects
  a payload with none).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…d (BLO-22705)

Master gained a new direct db.insert(approvals) call site (approvals.ts's
createWithIdempotency, added after this PR forked) that the
approval-payload-title-guard AST scan correctly flagged as unverifiable.
The route calling it (POST /companies/:companyId/approvals) already
enforces payload.title via createApprovalSchema, but the guard's job is to
require that structurally, not take it on faith — matching the pattern
already used by create() above it, route the insert through
insertApprovalRecord() instead of a bare db.insert(approvals).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast
allyblockcast Bot force-pushed the fix/blo-22705-untitled-approval-producers branch from 0ea6485 to cf1e3aa Compare August 10, 2026 22:29
@allyblockcast

allyblockcast Bot commented Aug 10, 2026

Copy link
Copy Markdown
Author

Re-authored cb1ee6a, and found+fixed a genuine new gap the guard caught

Per the handoff above: re-authored the App-attributed commit (cb1ee6a → now 57050e36, author PlatformSREEngineer <platformsreengineer@paperclip.blockcast.net>) via git push (never the REST/MCP write path), and rebased the branch onto current master (d63bbc669) rather than re-merging, since master had moved further since the last update. policy's local check now passes:

$ node scripts/check-commit-author-attribution.mjs --base origin/master --head fix/blo-22705-untitled-approval-producers
  ✓  No commits in range carry the shared allyblockcast[bot] App identity.

Running the whole-tree guard test against current master surfaced a real, new offense — exactly the risk flagged when the branch was updated 190 commits forward:

services/approvals.ts:318 (payload is not a statically-checkable object literal)

createWithIdempotency's inner insertNew (added to master after this PR forked) does a bare client.insert(approvals).values({ ...data, ... }), bypassing the insertApprovalRecord() choke point this PR built for create() right above it. In practice this path is safe today — its only caller, POST /companies/:companyId/approvals, already validates payload.title via createApprovalSchema — but the guard's job is to make that structural, not assumed. Added a third commit (cf1e3aa0) routing it through insertApprovalRecord(), same pattern as create().

State

Head cf1e3aa0b17c0989cc837bdd7b214da41645f263
vs master 0 behind, linear (rebased, no merge commit)
Commits 3 — retitle producers (re-authored), narrow guard exemption (unchanged), route createWithIdempotency through the guard (new)
Local verification 6/6 relevant test files green (69 tests), tsc --noEmit clean, guard test passes against current master
CI just triggered at new head, queued

Paperclip issue: https://paperclip.blockcast.net/BLO/issues/BLO-22705

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: cf1e3aa

Prior Findings Dispositioned (1)

  • prior:0ea6485 important 1 — no-longer-applicable — server/src/services/approval-insert.ts:425 — the current helper implementation is on cf1e3aa0b17c0989cc837bdd7b214da41645f263, whose PR commit metadata is authored by PlatformSREEngineer; the earlier finding applied only to the replaced bot-authored commit that blocked CI provenance policy.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • insertApproval() makes the required non-blank title invariant explicit for server-internal producers at server/src/services/approval-insert.ts:425.
  • insertApprovalRecord() preserves the valid hire_agent subject fallback while rejecting payloads that cannot render a meaningful approval subject.
  • The AST guard limits unchecked direct inserts to the documented helper boundary and focused tests cover the producer regressions.

Recommended Action

  1. No blocking changes requested.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: bbcd4aa

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • gives server-internal producers a compile-time and runtime non-blank title contract before they can write an approval.
  • The generic insertion boundary preserves the intentional subject fallback while rejecting payloads without any renderable subject.
  • Focused helper and producer tests, plus the structural direct-insert guard, cover the newly enforced invariant.

Recommended Action

  1. No blocking changes requested. Let the queued CI checks complete before merge.

@kkroo
kkroo requested a review from allyblockcast August 11, 2026 22:29
@allyblockcast
allyblockcast added this pull request to the merge queue Aug 11, 2026
Merged via the queue into master with commit b3dc156 Aug 12, 2026
18 checks passed
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