feat(UPG-833): §B pre-close SHA validation hook in control-plane PATCH /issues/:id#6528
Conversation
…plane
- Add `server/src/services/closureGate.ts` — pure detector module (extractShas,
extractCitedPaths, isProcessOnlyDeclared, validate) with injectable runGit for
testability; handles NO_TEXT, NO_HEAD_SHA, INVALID_HEAD_SHA,
PROCESS_ONLY_UNDECLARED, PATH_PROOF_MISMATCH rejection codes
- Add 24 unit tests in `server/src/__tests__/closure-gate.test.ts`
- Add 10 integration tests in `server/src/__tests__/issue-closure-gate-routes.test.ts`
covering all AC cases including bypass, process-only, shadow mode, and NO_WORKSPACE
- Wire gate into PATCH /issues/:id: validates on status→done transition using
executionWorkspace.cwd (or providerRef) as repoPath, defaultBranch from
workspace.baseRef, PAPERCLIP_DISABLE_CLOSURE_GATE and PAPERCLIP_CLOSURE_GATE_SHADOW
env flags, bypassClosureGate { reason } override field
- Extend updateIssueSchema with bypassClosureGate (reason ≥10 chars) in
packages/shared/src/validators/issue.ts
- Add issue_closure_gate_overrides schema + migration (0087_melodic_chronomancer.sql)
- Append §B Pre-Close SHA Validation Hook docs to doc/DEVELOPING.md and AGENTS.md
Co-Authored-By: Paperclip <[email protected]>
Greptile SummaryThis PR adds a server-side §B "closure gate" that requires every
Confidence Score: 3/5The gate enforcement logic is sound, but two defects in the route handler need fixing before merge. The bypass path never writes to the dedicated audit table that was built and migrated for exactly this purpose, so the override audit trail is broken from day one. Additionally, the bypass field cannot unblock the no-workspace rejection path, which contradicts its documented role as the per-request escape hatch. Both issues are in the critical hot path of the feature being shipped. server/src/routes/issues.ts — the bypass branch needs a db.insert into issue_closure_gate_overrides and must be evaluated before the !repoPath guard. Important Files Changed
Prompt To Fix All With AIFix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
server/src/routes/issues.ts:3676-3687
**Bypass does not persist to the dedicated audit table**
The `bypassClosureGate` branch only calls `logActivity` — it never inserts a row into `issue_closure_gate_overrides`, the audit table created in this same PR. The table has a `NOT NULL` `detector_findings` column plus `override_reason`, `actor_agent_id`, and `actor_user_id` fields that are left completely unpopulated. Overrides are therefore only recoverable from the general activity log, not from the dedicated table that was built and migrated for exactly this purpose.
### Issue 2 of 3
server/src/routes/issues.ts:3656-3676
**`bypassClosureGate` is silently ignored when no workspace is available**
The `!repoPath` branch runs before the `bypassClosureGate` check, so an agent that supplies a valid bypass reason on an issue without an `executionWorkspaceId` still receives a hard `422 CLOSURE_GATE_REJECTED` (`NO_WORKSPACE`). The bypass is documented as the per-request escape hatch, but it cannot override the workspace-absent case — the only way out is the global `PAPERCLIP_DISABLE_CLOSURE_GATE` kill-switch. Moving the `bypassClosureGate` check to the top of the gate block (before the repoPath check) would make the bypass consistent with its stated purpose.
### Issue 3 of 3
server/src/services/closureGate.ts:108-123
**`PROCESS_ONLY_UNDECLARED` emitted even when `NO_HEAD_SHA` is the root cause**
When text contains no SHA and no cited paths and is not process-only, the function pushes both `PROCESS_ONLY_UNDECLARED` (line 109) and then `NO_HEAD_SHA` (line 117) in the same rejection list. A caller who sees both codes must know to prioritize `NO_HEAD_SHA` — a comment with only a HEAD SHA would satisfy `PROCESS_ONLY_UNDECLARED` anyway. Gating the `PROCESS_ONLY_UNDECLARED` push behind a `headSha !== null` check, or moving the `NO_HEAD_SHA` guard before the path check, would produce cleaner, unambiguous rejection lists.
Reviews (1): Last reviewed commit: "feat(UPG-833): implement §B pre-close SH..." | Re-trigger Greptile |
| } else if (bypassClosureGate) { | ||
| await logActivity(db, { | ||
| companyId: existing.companyId, | ||
| actorType: actor.actorType, | ||
| actorId: actor.actorId, | ||
| agentId: actor.agentId, | ||
| runId: actor.runId, | ||
| action: "issue.closure_gate_overridden", | ||
| entityType: "issue", | ||
| entityId: existing.id, | ||
| details: { override_reason: bypassClosureGate.reason }, | ||
| }); |
There was a problem hiding this comment.
Bypass does not persist to the dedicated audit table
The bypassClosureGate branch only calls logActivity — it never inserts a row into issue_closure_gate_overrides, the audit table created in this same PR. The table has a NOT NULL detector_findings column plus override_reason, actor_agent_id, and actor_user_id fields that are left completely unpopulated. Overrides are therefore only recoverable from the general activity log, not from the dedicated table that was built and migrated for exactly this purpose.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/routes/issues.ts
Line: 3676-3687
Comment:
**Bypass does not persist to the dedicated audit table**
The `bypassClosureGate` branch only calls `logActivity` — it never inserts a row into `issue_closure_gate_overrides`, the audit table created in this same PR. The table has a `NOT NULL` `detector_findings` column plus `override_reason`, `actor_agent_id`, and `actor_user_id` fields that are left completely unpopulated. Overrides are therefore only recoverable from the general activity log, not from the dedicated table that was built and migrated for exactly this purpose.
How can I resolve this? If you propose a fix, please make it concise.| if (!repoPath) { | ||
| const noWorkspaceRejection = { | ||
| code: "NO_WORKSPACE" as const, | ||
| message: "No execution workspace resolvable for §B validation.", | ||
| }; | ||
| await logActivity(db, { | ||
| companyId: existing.companyId, | ||
| actorType: actor.actorType, | ||
| actorId: actor.actorId, | ||
| agentId: actor.agentId, | ||
| runId: actor.runId, | ||
| action: closureGateShadow ? "issue.closure_gate_would_reject" : "issue.closure_gate_rejected", | ||
| entityType: "issue", | ||
| entityId: existing.id, | ||
| details: { rejections: [noWorkspaceRejection], gate_disabled_at_startup: false }, | ||
| }); | ||
| if (!closureGateShadow) { | ||
| res.status(422).json({ error: "CLOSURE_GATE_REJECTED", rejections: [noWorkspaceRejection] }); | ||
| return; | ||
| } | ||
| } else if (bypassClosureGate) { |
There was a problem hiding this comment.
bypassClosureGate is silently ignored when no workspace is available
The !repoPath branch runs before the bypassClosureGate check, so an agent that supplies a valid bypass reason on an issue without an executionWorkspaceId still receives a hard 422 CLOSURE_GATE_REJECTED (NO_WORKSPACE). The bypass is documented as the per-request escape hatch, but it cannot override the workspace-absent case — the only way out is the global PAPERCLIP_DISABLE_CLOSURE_GATE kill-switch. Moving the bypassClosureGate check to the top of the gate block (before the repoPath check) would make the bypass consistent with its stated purpose.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/routes/issues.ts
Line: 3656-3676
Comment:
**`bypassClosureGate` is silently ignored when no workspace is available**
The `!repoPath` branch runs before the `bypassClosureGate` check, so an agent that supplies a valid bypass reason on an issue without an `executionWorkspaceId` still receives a hard `422 CLOSURE_GATE_REJECTED` (`NO_WORKSPACE`). The bypass is documented as the per-request escape hatch, but it cannot override the workspace-absent case — the only way out is the global `PAPERCLIP_DISABLE_CLOSURE_GATE` kill-switch. Moving the `bypassClosureGate` check to the top of the gate block (before the repoPath check) would make the bypass consistent with its stated purpose.
How can I resolve this? If you propose a fix, please make it concise.| if (!processOnly && citedPaths.length === 0 && subShas.length === 0) { | ||
| rejections.push({ | ||
| code: "PROCESS_ONLY_UNDECLARED", | ||
| message: | ||
| "No cited paths found. Implementation tickets require per-path reachability proofs (`git log <branch> --oneline -- <path>`). If process-only, add 'cites no in-repo artifact' to the closing comment.", | ||
| }); | ||
| } | ||
|
|
||
| if (headSha === null) { | ||
| rejections.push({ | ||
| code: "NO_HEAD_SHA", | ||
| message: | ||
| "No HEAD sha found. Run `git log <branch> --oneline -1` and paste the output in the closing comment.", | ||
| }); | ||
| return { ok: false, rejections }; | ||
| } |
There was a problem hiding this comment.
PROCESS_ONLY_UNDECLARED emitted even when NO_HEAD_SHA is the root cause
When text contains no SHA and no cited paths and is not process-only, the function pushes both PROCESS_ONLY_UNDECLARED (line 109) and then NO_HEAD_SHA (line 117) in the same rejection list. A caller who sees both codes must know to prioritize NO_HEAD_SHA — a comment with only a HEAD SHA would satisfy PROCESS_ONLY_UNDECLARED anyway. Gating the PROCESS_ONLY_UNDECLARED push behind a headSha !== null check, or moving the NO_HEAD_SHA guard before the path check, would produce cleaner, unambiguous rejection lists.
Prompt To Fix With AI
This is a comment left during a code review.
Path: server/src/services/closureGate.ts
Line: 108-123
Comment:
**`PROCESS_ONLY_UNDECLARED` emitted even when `NO_HEAD_SHA` is the root cause**
When text contains no SHA and no cited paths and is not process-only, the function pushes both `PROCESS_ONLY_UNDECLARED` (line 109) and then `NO_HEAD_SHA` (line 117) in the same rejection list. A caller who sees both codes must know to prioritize `NO_HEAD_SHA` — a comment with only a HEAD SHA would satisfy `PROCESS_ONLY_UNDECLARED` anyway. Gating the `PROCESS_ONLY_UNDECLARED` push behind a `headSha !== null` check, or moving the `NO_HEAD_SHA` guard before the path check, would produce cleaner, unambiguous rejection lists.
How can I resolve this? If you propose a fix, please make it concise.Cast `existing as { labels?: ... }` failed because IssueWithLabels.labels
is non-optional. Use double-cast via unknown to satisfy tsc.
Co-Authored-By: Paperclip <[email protected]>
Labels table has no slug column; schema has id, name, color, companyId, createdAt, updatedAt. Normalize name to kebab-case before comparing. Also remove erroneous slug field from test fixture. Co-Authored-By: Paperclip <[email protected]>
CI Status — Run 26267464328Typecheck + Release Registry: ✅ PASS General tests / serialized suites / e2e / verify: ❌ — all failing on the same pre-existing error: This is a baseline infrastructure issue on All 34 closure-gate tests (24 unit + 10 integration) pass locally via PR is MERGEABLE. Requesting merge to unblock UPG-833 §B canonical-main closure. |
… spec rev 2 Implements the two new rejection codes required by UPG-829 spec revision 2 (filed via UPG-838) before PR paperclipai#6528 can merge: - closureGate.ts: Extractor C regex now captures <REF> token alongside path. New extractCitedArtifacts() returns { path, ref? }[]. §4.4.0 ref-validation gate rejects INVALID_PROOF_BRANCH when ref ≠ defaultBranch or ref token is missing (malformed line). extractCitedPaths() kept as backward-compat wrapper. - issues.ts: §6.4 bypass deny-list check fires before manager-tier actor check. Reasons matching /pr.*(not.*merged|pending|open|review)/i reject with INVALID_BYPASS_REASON regardless of actor tier. - Integration tests: cases (k) non-default-branch path proof, (l) PR-not-merged bypass reason, (m) malformed ref-less line. - doc/DEVELOPING.md, AGENTS.md: document new rejection codes and <REF> requirement on path-proof paste shapes. All 24 unit tests and 13 integration tests pass. Co-Authored-By: Paperclip <[email protected]>
… spec rev 2 Implements the two new rejection codes required by UPG-829 spec revision 2 (filed via UPG-838) before PR paperclipai#6528 can merge: - closureGate.ts: Extractor C regex now captures <REF> token alongside path. New extractCitedArtifacts() returns { path, ref? }[]. §4.4.0 ref-validation gate rejects INVALID_PROOF_BRANCH when ref ≠ defaultBranch or ref token is missing (malformed line). extractCitedPaths() kept as backward-compat wrapper. - issues.ts: §6.4 bypass deny-list check fires before manager-tier actor check. Reasons matching /pr.*(not.*merged|pending|open|review)/i reject with INVALID_BYPASS_REASON regardless of actor tier. - Integration tests: cases (k) non-default-branch path proof, (l) PR-not-merged bypass reason, (m) malformed ref-less line. - doc/DEVELOPING.md, AGENTS.md: document new rejection codes and <REF> requirement on path-proof paste shapes. All 24 unit tests and 13 integration tests pass. Co-Authored-By: Paperclip <[email protected]>
Thinking Path
What Changed
server/src/services/closureGate.ts(new): pure detector module withextractShas,extractCitedPaths,isProcessOnlyDeclared,validate; injectablerunGitfor testability; rejection codes:NO_TEXT,NO_HEAD_SHA,INVALID_HEAD_SHA,PROCESS_ONLY_UNDECLARED,PATH_PROOF_MISMATCHserver/src/__tests__/closure-gate.test.ts(new): 24 unit tests for all detector functionsserver/src/__tests__/issue-closure-gate-routes.test.ts(new): 10 integration tests covering AC cases (a)–(j) for the PATCH routeserver/src/routes/issues.ts: gate block inserted beforelet issue;in PATCH /issues/:id; readsexecutionWorkspace.cwd(orproviderRef) as repo path; usesworkspace.baseReffor default branch; logsissue.closure_gate_rejected,issue.closure_gate_would_reject,issue.closure_gate_overriddento activity logpackages/shared/src/validators/issue.ts: addedbypassClosureGate: { reason: string (≥10 chars) }optional field toupdateIssueSchemapackages/db/src/schema/issue_closure_gate_overrides.ts(new): Drizzle schema for audit tablepackages/db/src/migrations/0087_melodic_chronomancer.sql(new): DDL forissue_closure_gate_overridestablepackages/db/src/schema/index.ts: exportissueClosureGateOverridesdoc/DEVELOPING.md: §B Pre-Close SHA Validation Hook section (env flags, rejection shape, override, process-only, cheapest detector)AGENTS.md: §B Closure Gate section for agent contributorsVerification
Manual API verification:
Risks
git cat-file -tandgit logare spawned synchronously per close transition. For large repos this adds ~10–50ms latency. Mitigated: only runs on status→done, not on every PATCH.executionWorkspaceIdare rejected in hard mode. Shadow mode and the kill-switch (PAPERCLIP_DISABLE_CLOSURE_GATE=true) allow operators to roll out gradually.issue_closure_gate_overridestable is additive; no columns changed on existing tables. Safe to deploy and roll back.bypassClosureGatefield: schema-validated (reason ≥10 chars) to prevent empty bypasses. All overrides are logged to the activity log.Model Used
claude-sonnet-4-6Checklist