feat(GLA-1114): issue-level approve gate — block approve when no visual asset attached - #5538
Closed
jqueguiner wants to merge 30 commits into
Closed
feat(GLA-1114): issue-level approve gate — block approve when no visual asset attached#5538jqueguiner wants to merge 30 commits into
jqueguiner wants to merge 30 commits into
Conversation
The partial unique index `issues_open_routine_execution_uq` previously
required execution_run_id IS NOT NULL, leaving a window where two
routine_execution siblings sharing (company_id, origin_id,
origin_fingerprint) could both be inserted with null execution_run_id. A
later UPDATE that populated execution_run_id (and kept the row in the
index) admitted both rows, wedging every subsequent write — including
the reaper's PATCH {status:"cancelled"} — with 23505.
- Migration 0082 hides newer duplicates and recreates the partial index
without the execution_run_id IS NOT NULL clause so collisions fire at
INSERT time. Rollback is documented inline.
- Schema mirrors the new predicate.
- Dispatcher coalesce path adds a fallback finder so a partial-unique
conflict before execution_run_id is populated still resolves to the
pre-existing sibling instead of bubbling 23505.
- Error handler lifts postgres-js error fields (code, constraint, table,
detail) so future failures surface in app frames instead of bare
driver stacks.
- Issue update route logs origin tuple + pg fields when svc.update
throws an unexpected error, surfacing the failing call site.
- Regression test covers (a) duplicate sibling INSERT now fails with
23505/issues_open_routine_execution_uq, (b) cancellation PATCH on a
sibling whose execution_run_id is populated returns successfully, and
(c) hidden prior siblings free the index slot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…GLA-441) Add scoped bypass in assertAgentIssueMutationAllowed: when the caller is a security-role agent and the target issue is critical or high priority, allow POST /comments through with an audit log row (cross_assignee_security_comment, safeguard_role_bypass=true). Gate stays scoped to comments — PATCH issue, PUT documents, and other mutation paths still 403. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Tighten GLA-441's role=security cross-assignee comment bypass: now also requires the caller's agent id be in SAFEGUARD_COMMENT_ALLOWLIST (only the SafeguardReviewer for now). Other security-role agents (e.g. SecurityEngineer) lose the bypass and get the standard 409 path. Stamp comment metadata.safeguardBypass=true on the bypassed insert so reviewers can see at a glance why a non-assignee posted; widens the audit log row from GLA-441 with a per-comment artifact. Schema gets a new optional safeguardBypass boolean on IssueCommentMetadata. Tests: 8 cases — allow on high+critical with metadata stamped, deny on medium, deny non-allowlisted role=security, deny cross-company, deny PATCH, deny engineer, plus assignee-self path unchanged (no bypass flag). Co-Authored-By: Paperclip <noreply@paperclip.ing>
Productivity reaper now skips long_active_duration triggers when the source issue has at least one non-terminal blocker via issue_relations, or at least one non-terminal direct child assigned to a different agent. no_comment_streak and high_churn triggers are never downgraded. Emits a structured productivity_review.suppressed_long_active log line with reason, blockerCount, and nonTerminalChildCount when the suppressor fires. Implements Option A from GLA-597. No schema change. Rollback: git revert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ssor tests - Export listUnresolvedBlockerIssueIds from server/src/services/issues.ts so productivity-review can reuse the cancelled-blocker-stays-unresolved semantic the rest of the issue service already relies on. - Import the helper in productivity-review.ts (call site at collectEvidence was missing the symbol after GLA-599 landed, so the suppressor path threw ReferenceError on the blocker branch). - Remove three duplicate long_active suppressor tests now covered by the GLA-599 test set; keep the GLA-437 multi-child + blockedBy repro test. Verification: pnpm --filter @paperclipai/server exec vitest run productivity-review-service (17/17 green). Rollback: git revert.
PAPERCLIP_API_KEY was captured as '' at module init (line 19) before the env-sourcing block ran (line 34+). Moved sourcing into an IIFE-style block immediately after imports so all process.env keys are populated before any const reads them. Also added PAPERCLIP_API_KEY to notifier.env so launchd/cron invocations (which don't inherit the interactive shell env) pick it up automatically. Fixes slot-1650 failure reported on GLA-77. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add plugin-github-sync: manifest with events.subscribe, plugin.state.read/write, secrets.read-ref, http.outbound, goals.read capabilities; instanceConfigSchema for repo/host/secretRef/syncedGoalIds/dryRun; no-op handlers for issue.created, issue.updated, goal.updated that log sanitised payload and resolve goal-ancestor chain. 4/4 unit tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ated test - Add tests/github-client.spec.ts: 19 tests covering PAT redaction, rate-limit parsing, backoff on 429/403, createIssue/closeIssue/ reopenIssue/addLabel methods, redirect:error enforcement. - Fix existing plugin.spec.ts goal.updated assertion: the handler logs "github-sync: goal.updated — cache invalidated", not the generic no-op message. Test now checks the correct message for each event type. All 23 tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ADME depth docs - Add goal-subtree-cache.spec.ts: 12 tests covering 3-level tree (in-scope branch syncs, out-of-scope does not, root-less skipped), cache hit/miss, TTL expiry, invalidation scoped per company, 5-level depth fixture, and cycle-guard termination - Document real-world goal tree depth (2–3 typical, 5 max) in README Co-Authored-By: Paperclip <noreply@paperclip.ing>
… sanitisation - Add src/github-client.ts (missed from prior commits) - Add src/goal-subtree-cache.ts (missed from prior commits) - Add src/sync-engine.ts: sanitiseBody, buildTitle, mapIssueStatus, createSyncEngine with doSync + scheduleSync (debounce 2 s per issue) - Update src/manifest.ts: add issues.read capability - Refactor src/worker.ts: replace no-op handler with engine.scheduleSync via routeEvent; keep company/goal-subtree filter chain intact - Add tests/sync-engine.spec.ts: 23 tests covering sanitiser unit tests, idempotency (create + 3 replays = 1 POST), audit log, status transitions (done/cancelled/open), dry-run gate, debounce - Update tests/plugin.spec.ts: replace no-op assertions with new dry-run log assertions, out-of-scope short-circuit, goal.updated cache 60/60 tests pass; typecheck clean. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…tart
W1A of GLA-927 — unblock the marketing freeze (GLA-964) by exposing a
local UI for the Paperclip [review-and-ship] queue.
- scripts/asset-library: Next 14.2.18 + React 18.3.1 + Tailwind 3 app router
- /: placeholder ("Asset Library — coming soon")
- /api/issues: server-side proxy to PAPERCLIP_API_URL companies issues,
filtered to titles starting with [review-and-ship]
- ecosystem.config.js: pm2 wrapper, pinned to /opt/homebrew/opt/node@20,
loads notifier.env for PAPERCLIP_API_KEY etc.
- launchd/io.gladia.asset-library.plist: autostart on login via
`pm2 resurrect || pm2 start`
- README documents run/install/verify
Verified: curl http://127.0.0.1:7700/ returns 200; /api/issues returns
the [review-and-ship] subset.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ration - POST /api/companies/:id/integrations/github — set/replace config (repo, host, secretRef, syncedGoalIds[], dryRun). Validates repo shape, secretRef resolves to a secret in the company, syncedGoalIds exist in the company. - GET /api/companies/:id/integrations/github — read config. secretRef returned (UUID ref only), PAT value never surfaced. - DELETE /api/companies/:id/integrations/github — disable integration; existing GH issues stay in place. - POST /api/issues/:id/sync-to-github — synchronous reconcile for one issue. dryRun:true returns planned payload with no GH API call. Live runs resolve the secret, upsert the GH issue, and update plugin state + lastError. Config stored in pluginCompanySettings.settingsJson for paperclipai.plugin-github-sync. Board-only auth, company-access checked on every route. logActivity calls record configure/disable events. UI: adds "GitHub sync (one-way, beta)" section to CompanySettings with repo, host, write-only PAT (existing secret picker or new PAT entry), goal multi- select, dryRun toggle, status row (last error + last sync timestamp), and disable button. API client added at ui/src/api/github-integration.ts. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Founder hit ENOENT loop after `pm2 resurrect` because a fresh checkout has no `.next/` and `next start` cannot run without it. Manual `npm run build` unblocked it. This wraps the start path so pm2/launchd handle the build themselves. - start.sh: pin node@20, run `npm run build` if `.next/BUILD_ID` is missing or any source under `app/` / config files are newer than the build, otherwise exec `next start`. Soft-fail: if rebuild fails but a previous `.next/` exists, log + serve the stale artifact instead of pm2-looping; refresh BUILD_ID mtime so we don't retry on every restart. - ecosystem.config.js: route pm2 through start.sh (`interpreter: bash`), drop the explicit node interpreter — start.sh sets PATH itself. Verified: pm2 restart picks up start.sh, logs "build is fresh — skipping rebuild" when artifact is current; with the current broken `lib/forbidden-source-scan.ts` TS error in W1B work, start.sh logs the fallback warning and still serves :7700 → 200. Re GLA-981 post-mortem. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Adds upload-time scan rejecting Shutterstock / Unsplash / Getty / Adobe Stock assets before they hit Paperclip storage. Filename regex + EXIF URL inspection via exifr. New /api/upload route forwards clean files to the Paperclip attachments endpoint; matches return HTTP 400 with forbidden-source body. Acceptance smoke (scripts/scan-smoke.ts, scripts/upload-route-smoke.ts): - shutterstock_123456.jpg -> 400 forbidden-source - AdobeStock_98765.png -> 400 forbidden-source - GettyImages-99999_final.jpg -> 400 forbidden-source - clean local-flux-output.png -> not forbidden Co-Authored-By: Paperclip <noreply@paperclip.ing>
…brary board-report and email-notifier now detect [review-and-ship] / [marketing-asset] issues and emit asset-library detail-page links (http://127.0.0.1:7700/asset/{issueId}/{docKey}) instead of plain Paperclip issue links. - email-notifier: when a blocked issue is asset-review, build an [asset-review]-prefixed email with one CTA per document, inline thumbnail (PNG/JPG via base64 data URI, capped at ASSET_LIBRARY_THUMBNAIL_MAX_BYTES=200KB), "▶ Watch video" link for video attachments, and a secondary issue-thread link. - board-report: per-row primary anchor swaps to the asset library URL for asset-review issues; deliverables list becomes per-doc CTA links; rows are tagged [asset-review]. - ASSET_LIBRARY_URL env (default http://127.0.0.1:7700) drives both. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…pulates The GET /integrations/github endpoint reads lastSyncAt from the pluginLogs table, but POST /issues/:id/sync-to-github never inserted rows there — neither in dryRun mode nor on live create/update/error paths. Insert an info-level log row before returning in all three paths so that the status row in Company Settings shows the last sync timestamp. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Current owner can now PATCH assigneeAgentId to transfer a routine to another agent in the same company. Non-owners remain blocked. Covers all four matrix cases from GLA-1043 spec. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
…rary
Wires the W2B approve/reject flow on the marketing asset library detail
page. Hard-blocks approval when provenance is missing, names a cloud-API
tool with no valid waiver, or matches a stock-library filename pattern.
Mirrors the gate server-side so the API rejects 422 even if the client
is bypassed.
- lib/asset-type.ts: parseProvenance reads metadata.provenance / YAML
frontmatter / "Provenance:" block; evaluateApprovalGate returns
{allowed,status,banner,bannerTone,...} per AC matrix (missing | cloud
| cloud-with-exception | exception-pending | exception-invalid |
stock | ok). CLOUD_TOOL_PATTERNS matches Runway/Pika/Sora/Heygen/
Synthesia/Midjourney/DALL-E/ElevenLabs/Suno/Udio (case-insensitive,
partial). STOCK_FILENAME_PATTERNS catches shutterstock_<n>/
GettyImages-<n>/AdobeStock_<n>.
- app/api/exception-check/route.ts: server-side waiver lookup —
exception is valid only when status==done AND title contains
[tool-exception].
- app/api/issues/[id]/approve/route.ts: re-evaluates gate against
upstream document, posts approval comment, PATCHes status:"todo".
- app/api/issues/[id]/reject/route.ts: requires note, posts rejection
comment, PATCHes status:"in_progress". 4000-char note cap.
- app/asset/[id]/[doc]/ApproveRejectBar.tsx: button row + reject
modal; APPROVE disabled when !gate.allowed; banner toned ok/warn/
block; uses /api proxy routes (server holds PAPERCLIP_API_KEY).
- scripts/gate-check.ts: AC harness — all 7 cases pass
(missing/cloud-no-waiver/cloud-with-valid-waiver/cloud-with-invalid-
waiver/local-ai-ok/stock-blocks-even-with-local-ai/founder-original).
Detail page integration (page.tsx) and ProvenancePanel/AssetRenderer/
MarkdownView are W2A (GLA-984) deliverables — left untracked here for
that owner to land. Live verified against pm2 asset-library on
http://127.0.0.1:7700/asset/GLA-917/post-body — gate banner "Cannot
approve — missing provenance metadata" renders, APPROVE button
disabled.
Acceptance criterion for GLA-1026 was: pm2/launchd restart returns HTTP 200 with zero manual `npm run build`. start.sh already enforces this (commit 3580c7f). What was missing was a CI/local-runnable smoke that exercises the same chain end-to-end without touching the running pm2 process. - scripts/build-start-smoke.sh: runs `next build` then `next start -p 17700` (separate port from prod :7700), polls 30s for HTTP 200, kills the spawned server on exit. Exits non-zero on timeout. - package.json: `npm run smoke` calls the helper. Verified locally: `npm run smoke` → "[smoke] HTTP 200 on :17700 — ok". Re GLA-1026. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…issues A routine's owning agent (e.g. OpsReaper) may now PATCH `status=cancelled` on its own routine_execution issues even when the issue is checked out by another assignee (e.g. SafeguardReviewer leaking executions during a crash loop). Scope is narrow: - only when `originKind=routine_execution` - only when the request body sets `status=cancelled` and nothing else (besides an optional `comment`) - only when the actor agent equals the parent routine's `assigneeAgentId` - routine company must match issue company Every use writes a `cross_assignee_routine_owner_cancel` activity log row with routineId, prior assignee, and prior status, so the bypass leaves an audit trail. Unblocks the safeguard reaper (GLA-1045) and closes the API permission gap surfaced by GLA-1029. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…l leaked routine_execution issues Adds a permission-grant carve-out alongside the existing routine-owner carve-out (GLA-1048) so an agent holding the new `tasks:cancel_routine_execution` permission can PATCH status=cancelled on a routine_execution issue assigned to a different agent. Spec §11.a. - New permission key: `tasks:cancel_routine_execution`. - `assertAgentIssueMutationAllowed` now accepts a `routineExecutionCancelByPermission` option and bypasses the foreign-issue 403 only when the issue origin is `routine_execution`, the requested status is `cancelled`, and the actor agent has the permission grant. Other fields beyond status / comment are rejected with 403 by the PATCH handler, matching the routine-owner carve-out. - Logs `cross_assignee_routine_execution_cancel` activity with the caller, prior assignee, prior status, and origin metadata. - Migration 0083 grants the permission to OpsReaper (`16d0232f-1249-4db9-82d6-ab237c926e59`) at the Gladia company (`050de589-23d3-40bb-b227-efea13164d01`). Conditional INSERT on company + agent existence; idempotent via the unique index. Rollback in the migration footer (single DELETE). Verified: - pnpm --filter @paperclipai/server typecheck (clean) - pnpm --filter @paperclipai/shared typecheck (clean) - vitest run on server/src/__tests__/issue-routine-execution-cancel-permission-route.test.ts (5 tests), issue-routine-owner-cancel-carveout.test.ts (5 tests), issue-agent-mutation-ownership-routes.test.ts (14 tests) — all green. Manual smoke deferred to CTO per acceptance. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…very runbook - README: full user-facing docs covering what the plugin does/does not do, config fields, dry-run workflow, disabling, status mapping, goal-subtree filtering, and PAT setup. - CHANGELOG: initial 0.1.0 entry covering all features and security invariants. - Recovery runbook stored as GLA-816 issue document: audit-log queries, manual GitHub close/delete steps, plugin_state mapping removal, and syncedGoalIds fix workflow. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…tbeat injection Adds native company-level shared_instructions to the Paperclip control plane so founder-policy lives in one canonical record and is prepended to every agent's resolved instructions file at heartbeat time, replacing the filesystem-walk-and-patch model. Schema (migration 0084_shared_instructions.sql): - companies.shared_instructions (nullable text) - agents.shared_instructions_opt_out (boolean, default false) - company_shared_instructions_history (append-only audit log: previous_value, new_value, actor_kind, actor_user_id, request_id, created_at) Rollback statements documented in the migration header. API: - PATCH /api/companies/:id/shared_instructions — board-only write, S1 gate rejects every agent JWT with 403 forbidden_actor_kind. Each successful PATCH appends a history row capturing previous + new values + actor (S2). - GET /api/companies/:id/shared_instructions/history — cursor pagination, reverse-chronological. - PATCH /api/agents/:id — extended to handle sharedInstructionsOptOut with the same S1 board-only gate (S3). - GET /api/agents/:id — includes sharedInstructionsOptOut via the existing full-row select. Heartbeat injection (server/src/services/heartbeat.ts): After the final runtimeConfig is assembled and before adapter.execute, the heartbeat resolves the company's shared_instructions and the agent's opt-out, then calls resolveSharedInstructions to materialize a per-run merged temp file (policy + "\n\n---\n\n" + original AGENTS.md content) under os.tmpdir and swaps runtimeConfig.instructionsFilePath to that path. The temp file is cleaned up in the outer finally block alongside other per-run cleanup. Tests (server/src/__tests__/company-shared-instructions.test.ts): Covers all six acceptance criteria from GLA-873: 1. Policy prepended ahead of role-specific instructions in the merged file. 2. Latest policy used on subsequent runs without restart. 3. Null/empty policy leaves the agent prompt unchanged. 4. Agent JWT (general/cto/cmo/ceo) PATCH returns 403 forbidden_actor_kind. 5. Successful write inserts a history row with previous + new + actor. 6. shared_instructions_opt_out=true skips the prepend for that agent only. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…ation config
When the `repo` field changes, issue a one-time GET /repos/{owner}/{repo}
with the configured PAT before writing the new settings. 401/403/404
responses throw unprocessable(422); the token is redacted from the error
body before it is surfaced. Unchanged repo skips the handshake entirely.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
…stError, or plugin_logs Verifies the redact() fix (landed in GLA-1076) covers all three error-path leak surfaces: the 502 JSON response, plugin_company_settings.lastError, and plugin_logs.meta.error. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Guard against accidental full-company mirror: POST /integrations/github now returns 422 when dryRun is disabled but syncedGoalIds is empty. Matches the safe-default requirement from the GLA-38 threat model. Co-Authored-By: Paperclip <noreply@paperclip.ing>
… CI smoke - watch-and-build.mjs: fs.watch over app/lib/components/pages, 5s debounce, npm run build + pm2 restart asset-library; runs as asset-library-watcher pm2 process - ecosystem.config.js: register asset-library-watcher alongside asset-library - ChunkErrorBanner.tsx: window error listener catches ChunkLoadError, shows "Library is rebuilding — refresh in 10s" overlay with auto-reload countdown - layout.tsx: mount ChunkErrorBanner in root layout - asset-library-smoke.yml: GitHub Actions job triggered on asset-library/** changes; npm ci → npm run build → next start → curl / (200) → curl /asset/:id (200) Co-Authored-By: Paperclip <noreply@paperclip.ing>
… paths When an issue has 0 IssueDocuments but its description references workspace paths (`.planning/`, `_default/`, `produced/`, `workspaces/<uuid>/`, `/Users/.../.paperclip-worktrees/...`), surface a red banner offering one-click auto-resolve. The endpoint walks each matched path on local disk, uploads it as an IssueDocument (text formats only, ≤512KB), rewrites the description to use `paperclip-doc:<key>` references, and inline-warns paths that fail to resolve. E2E verified against sandbox GLA-1104 (cancelled): 2 paths resolved to documents (brief-27-yt-long-eval-recipe.md, evidence-log.md), 1 missing path inline-warned with `⚠️ (file not found on server)`. Co-Authored-By: Paperclip <noreply@paperclip.ing>
…al attached Implements the visual-mandatory check at the [review-and-ship] issue level: - lib/asset-type.ts: add IssueApprovalGate type, evaluateIssueApprovalGate(), hasVisualAsset(), hasVisualWaiver() — detects image/video docs; recognises [visual-waiver] comments as a founder override (warning, not hard block) - app/components/IssueApproveBar.tsx: new client component — red banner + disabled Approve button when gate blocked; amber banner + enabled when visual-waiver; approve POSTs to the issue-level approve endpoint - app/components/AssetIssueSummaryView.tsx: accepts gate + paperclipUrl props; renders IssueApproveBar below the description section - app/api/issues/[issueId]/approve/route.ts: handles issue-level approve when docKey is absent — fetches docs + comments, runs evaluateIssueApprovalGate, posts approval comment + PATCHes status to todo on pass; per-doc path unchanged - app/asset/[issueId]/page.tsx: fetches comments server-side, computes issue gate, passes gate + paperclipUrl to AssetIssueSummaryView - app/asset/[issueId]/[docKey]/page.tsx: same gate computation for the canonical-slug fallback render path Co-Authored-By: Paperclip <noreply@paperclip.ing>
Contributor
|
Too many files changed for review. ( |
Author
|
Moving all my contributions to my private fork. Closing this PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Thinking Path
What Changed
lib/asset-type.ts: addIssueApprovalGatetype,evaluateIssueApprovalGate(),hasVisualAsset(),hasVisualWaiver()— detects image/video docs by extension/format/body heuristics; recognises[visual-waiver]comments as a downgrade from block to warningapp/components/IssueApproveBar.tsx: new client component — shows banner (red/amber/none), APPROVE BRIEF button disabled when gate blocks, POSTs to issue-level approve endpoint on clickapp/components/AssetIssueSummaryView.tsx: acceptsgate+paperclipUrlprops, rendersIssueApproveBarbelow the description sectionapp/api/issues/[issueId]/approve/route.ts: whendocKeyis absent (issue-level approve), fetches all docs + comments, runsevaluateIssueApprovalGate, posts approval comment + PATCHes status totodoon pass; existing per-doc path unchangedapp/asset/[issueId]/page.tsx: fetches comments server-side (direct Paperclip API call), computes issue gate, passes gate +paperclipUrltoAssetIssueSummaryViewapp/asset/[issueId]/[docKey]/page.tsx: same gate computation + props for the canonical-slug fallback render pathVerification
/asset/{text-only-brief}→ red bannerCannot approve — no image/video asset attached…+ APPROVE BRIEF button disabledtodo[visual-waiver]comment on issue → reload → amber warning banner, button enabled/asset/{issueId}/{docKey}still works unchanged (docKey present → existing provenance gate path)npm run buildpasses clean (verified locally)Risks
/api/issues/{id}/comments— if that endpoint doesn't exist or returns unexpected shape,fetchCommentsreturns[](safe default: gate evaluates as no-waiver, blocks without visual). No hard failure.evaluateIssueApprovalGateis called on every page load for issue-level routes; it's a pure local function with two O(n) array scans — negligible cost.docKey-present calls; the new branch only runs whendocKeyis absent.Model Used
Checklist