ce-code-review pass 03 — claude-opus-5
Scope: git diff origin/main..HEAD (base 070cb10, 99 files, +7338/-102).
Reviewers: correctness, security, testing, maintainability, project-standards, performance,
api-contract, data-migration, reliability, julik-frontend-races, agent-native, learnings.
The standalone adversarial pass runs as its own spawn; findings below were reached
independently and two of them corroborate it.
High
- [x] AUTO-FIX: Removal tombstones count toward the five-file ceiling at commit but not at reservation, so remove-then-replace permanently wedges a composer:
reserve_slot!countsbatch.image_attachments.active_drafts.count >= ImageAttachment::MAX_FILES(app/services/image_attachments/ingest.rb:99) whileensure_slot_available!counts every row including tombstones —batch.image_attachments.where.not(id: attachment.id).count < ImageAttachment::MAX_FILES(app/services/image_attachments/ingest.rb:254); commitcbb7be6switched the reservation side toactive_draftsand left the commit side behind, so "attach five, remove one, attach a replacement" streams and fully decodes the new file and only then raisestoo_many_files,record_failureturns the rejected row into afailedrow that occupies anactive_draftsslot of its own, andClaimBatch#validate_attachments!then blocks the post withattachments_not_readywith no recovery short of abandoning the composer —total_byte_size(app/models/image_attachment_batch.rb:95) andDraftPresenter.batchboth useactive_drafts, which makes line 254 the one unintentional divergence, andtest/services/image_attachments/ingest_test.rb:107("rejects a sixth file") never involves a removal. <!-- triage: confirmed by reading ingest.rb:99/254 — count active_drafts on the commit side too, plus a remove-then-replace test --> - [x] AUTO-FIX: A claimed batch replayed against the other composer endpoint 500s in one direction and silently succeeds without writing in the other:
ClaimBatch#claimshort-circuits toreplayonnext replay if batch.state_claimed?beforevalidate_ownership!and hands backbatch.claimed_parent, anAnnotationor anAnnotationComment(app/services/image_attachments/claim_batch.rb:47,60-66), whilesubmission_batchonly verifies uploader and project and never that the claimed parent matches the endpoint (app/controllers/concerns/image_attachment_submission.rb:22-30) — so posting a reply batch's public ID toAnnotationsController#createreachesrespond_created(result.parent)→annotation.viewport(app/controllers/annotations_controller.rb:17,57-66) and raisesNoMethodError(500) instead of an ordinary 422, and posting a root batch's ID toAnnotationCommentsController#creatediscards theClaimBatch.callreturn value entirely (app/controllers/annotation_comments_controller.rb:13) and answers201 createdhaving created no comment; plan Unit 4 names "cross-composer batches" in its required verification list andtest/controllers/image_attachment_submission_test.rbhas no such case — the response-loss retry tests only replay against the same endpoint. <!-- triage: confirmed — plan Unit 4 names cross-composer batches; reject a claimed parent that does not match the endpoint with 422 and cover both directions -->
Medium
- [x] AUTO-FIX: Attaching or removing a file during the interrupted-upload settling window can orphan an in-flight item or leave an untracked ghost row:
resumeBatchunconditionally clears and rebuildsthis.itemsand the DOM on every poll tick whilethis.resumingis true (app/javascript/controllers/image_attachment_composer_controller.js:288-290), butenqueue()(:172) andremove()(:454) guard only onthis.submitting, and the Remove button's disabled expression at:744likewise omitsthis.resumingeven though the alt-text input at:743includes it — so the reconnect path that pass 02 added is itself racy against user input. <!-- triage: confirmed at composer JS 172/454/744 — guard enqueue/remove and the Remove button on this.resuming like the alt input already is --> - [x] AUTO-FIX: Over-length alt text returns a 500 instead of the machine-code error envelope every other draft endpoint returns:
WriteAltText#callusesattachment.update!(alt_text: alt_text)(app/services/image_attachments/write_alt_text.rb:30) against avalidates :alt_text, length: { maximum: MAX_ALT_TEXT }(app/models/image_attachment.rb:49), andImageAttachmentDrafts::BaseControllerrescues onlyImageAttachments::ErrorandRateLimitStore::UnavailablewhileApplicationControllerrescues onlyActiveRecord::RecordNotFound(app/controllers/application_controller.rb:14) — the browser'smaxlengthhides it, but plan Unit 2 step 3 requires a machine code and actionable text on every draft response, and no test covers alt text longer thanMAX_ALT_TEXT. <!-- triage: plan Unit 2 step 3 requires a machine code on every draft response — rescue the length validation into ImageAttachments::Error and test it --> - [x] AUTO-FIX: The three new
image_attachment_batchesCHECK constraints are asserted to exist by name and never exercised:test/integration/database_constraint_contract_test.rb:24-27listsimage_attachment_batches_valid_state,_future_expiry, and_claim_statein the name-existence map only, and unlikeimage_attachments— whose_exclusive_parentand_submitted_readyconstraints do get raw-SQL rejection tests (test/models/image_attachment_test.rb:100-124) — no direct-SQL insert ever proves a batch constraint rejects bad data; there is also notest/models/image_attachment_batch_test.rbat all, sousable?,expired?, andtouch_activity!are only covered indirectly. <!-- triage: missing tests for defined behavior — add raw-SQL rejections for the three batch CHECKs and a batch model test --> - [x] AUTO-FIX:
ImageAttachmentDraftCleanupJob.enqueue_for_startup!is dead code that mimics a boot-time safety net without providing one: it is a verbatim copy ofReconcileScreenshotProcessingJob.enqueue_for_startup!down to theraise job.enqueue_errorbody (app/jobs/image_attachment_draft_cleanup_job.rb:15-21), butbin/docker-entrypoint:109calls only the screenshot job and nothing anywhere calls the attachment one —wiki/self-hosting.md:172documents that startup enqueue as the established convention, and the sibling has a dedicated test while this method has none; either wire it into the entrypoint or delete it and rely on the 15-minute recurring sweep. <!-- triage: dead code removal — plan Unit 2 step 4 is satisfied by the recurring schedule plus the readiness registration check, and the entrypoint contract test pins the current command list, so drop the unwired method --> - [x] AUTO-FIX: Every REST and MCP annotation-detail read preloads attachment blobs no serializer touches:
Api::V1::ProjectScope.annotation_detailspreloads{ image_attachments: { image_attachment: :blob } }for the root and every comment (app/services/api/v1/project_scope.rb:37-42), butImageAttachment#as_contract_jsonreads only plain columns (app/models/image_attachment.rb:120-131) andContractSerializer#image_attachment_media_urlonly callsgenerate_token_for(app/serializers/api/v1/contract_serializer.rb:153-158), whose payload is just the record ID — dead join weight on the exact pathget_annotation/screenote annotation gethits repeatedly. <!-- triage: confirmed — as_contract_json and generate_token_for touch no blob; drop the { image_attachment: :blob } nesting and keep the row preload --> - [x] AUTO-FIX:
ImageDecoding::Guard.synchronizeapplies the full timeout twice, so a composer can block ~10s before it is told the decoder is busy:acquire_key(key, timeout) { acquire_global(timeout, &block) }(app/services/image_decoding/guard.rb:29) passes the sameWAIT_SECONDS = 5to both the per-batch and the global stage, andIngest#decode!calls it with no timeout override — the keyed layer's own comment names multi-file composer work as its target case, which is exactly when both waits stack. <!-- triage: clear mechanism — share one deadline across the keyed and global stages instead of passing the full timeout twice --> - [x] AUTO-FIX: A failing COMMIT leaks the staged blob with no reconciliation path:
Ingest#commit!setsattached = trueas the last statement insideImageAttachment.transaction(app/services/image_attachments/ingest.rb:205) while the cleanup isensure discard_blob(blob) if blob && !attached(:211), so an exception raised by the COMMIT itself rolls the row back touploadingyet skips the purge; the blob was created outside the transaction bystage_blob!(:176,create_after_unfurling!) andImageAttachmentOrphanReconciliationJobonly scansImageAttachmentrows, so nothing ever reclaims it — movingattached = trueto after the transaction block closes it. <!-- triage: confirmed at ingest.rb:205/211 — moveattached = trueafter the transaction block so a failing COMMIT still purges the staged blob --> - [x] RESOLVED/NO-FIX: Design call — the agent read loop ends at metadata:
attachments[].urlrequires both the caller's ownAuthorization: Bearerheader and the 5-minute purpose token (app/controllers/api/image_attachment_media_controller.rb:20-33), butinternal/screenote/client.gohas no authenticated-fetch method and there is noscreenote attachment fetch/downloadsubcommand ininternal/cli/, whileinternal/cli/config.go:8-30deliberately reports onlyTokenSet bool— so an agent onboarded through the documentedscreenote loginOAuth flow has no sanctioned way to turn the URL into bytes short of scraping~/.config/screenote/config.toml, and the MCP surface has the same gap (the crop is inlined ascropped_image_base64; human-attached images are not). The plan's R9 only requires the metadata projection, so this is not a missing plan deliverable — flagging it because CLAUDE.md names the CLI the public agent contract and the headline workflow is "agent reads feedback with the CLI". <!-- triage: plan R9 and Scope Boundaries — detail reads deliver the metadata projection plus a short-lived URL (brainstorm A9); a newscreenote attachment fetchsubcommand is new public CLI surface the plan does not list, and the reviewer itself states this is not a missing plan deliverable -->
Nit
- [x] AUTO-FIX:
media_blobvariant resolution is duplicated rather than shared:app/controllers/image_attachment_media_controller.rb:45-54andapp/controllers/media_controller.rb:25-35have the same raw-variant early return,ALLOWED_VARIANTSlookup, andvariant(key).image&.blobshape (plus near-identical no-decode-on-GET comments), differing only in theattached?guard and the extradownloadvariant — both already includeBlobStreaming, which is the natural home for a sharedresolve_variant_blob. <!-- triage: polish — extract the shared resolve_variant_blob into BlobStreaming, which both controllers already include --> - [x] AUTO-FIX: The unsupported-media-type message is hardcoded in the composer JS and can drift from the server's:
"Attachments must be PNG, JPEG, or WebP images."appears at several call sites inapp/javascript/controllers/image_attachment_composer_controller.js(first at:131) with no link toImageAttachments::Error::UNSUPPORTED_MEDIA_TYPEorImageAttachment::ALLOWED_CONTENT_TYPES, and no test pins the two in sync. <!-- triage: polish — derive the copy from the server's accepted types or pin the two in sync with a test --> - [x] AUTO-FIX: The draft API declares two
rate_limitcalls without an explicit shared scope (app/controllers/image_attachment_drafts/base_controller.rb:16-23), so the user and IP budgets may be keyed per including controller rather than across the whole draft surface; if two independent budgets were intended, say so in a comment. <!-- triage: polish — make the intended rate-limit scoping explicit in code or a comment --> - [x] AUTO-FIX:
attachmentsis omitted rather than[]on the comment object returned by the REST write endpoints, becauseContractSerializer.annotation_commentgates the key onurl_optionstruthiness (app/serializers/api/v1/contract_serializer.rb:123-135) — harmless today since agent-authored replies and resolution comments can never carry attachments, but the same "comment" shape now differs across endpoints and nothing pins it. <!-- triage: plan Unit 6 steps 1 and 5 want canonical empty arrays — emitattachments: []on write-endpoint comments and pin it --> - [x] AUTO-FIX: The new attachment eager-load paths have no query-budget regression test despite an established convention:
test/controllers/api/v1/annotation_attachments_contract_test.rbreimplements its own inlinecount_queriessubscriber with a loose fixed ceiling instead of reusingcapture_app_queries/assert_variant_records_preloadedfromtest/controllers/projects_controller_test.rb, and every new attachment test uses exactly one annotation/comment/attachment, so an N+1 inPagesController#load_workspaceorannotation_detailswould not be caught. <!-- triage: missing test for defined behavior — reuse capture_app_queries/assert_variant_records_preloaded with multi-attachment fixtures --> - [x] AUTO-FIX: No system test mounts a root and a reply composer at the same time, though plan Unit 3's verification list names "concurrent composers" explicitly — batch isolation across simultaneously mounted composers is currently unproven end to end. <!-- triage: plan Unit 3 verification names concurrent composers — add the system test -->
- [x] AUTO-FIX: The new
wiki/log.d/*.mdfragments use two different internal formats — three follow the repo-wide Action / Pages-updated / Source structure, three are bare YAML frontmatter with neither section — which may matter to whatever compiles them intowiki/log.md. <!-- triage: polish — normalize the new log.d fragments to the repo-wide Action / Pages-updated / Source format -->
Coverage
- Verified against the current worktree, not the first feature commit; findings already fixed by
the
fix(attachments)commits on this branch were dropped. - Correctness reproduced the tombstone-cap defect by execution (rolled-back transaction, no repo mutation); testing reproduced both cross-composer directions with a throwaway integration test.
- Clean passes with no findings: project-standards (BEM, enums, service-object shape, MiniTest +
fixtures, importmap, wiki fragments present and
wiki/log.mduntouched) and api-contract (attachmentsalways present on detail root and every comment, absent on list rows, MCP golden key fixture preserved, Go nil-vs-empty round trip,url_expires_atderived from the sameMEDIA_TOKEN_EXPIRYconstant asgenerates_token_for). - Security found no exploitable authorization, ingest, CSRF, or delivery defect. Confirmed sound:
draft reads are uploader-scoped and a same-project collaborator gets 404; the bearer route
requires principal + scope + attachment-bound purpose token +
submitted?+ liveproject_access?; ingest derives media type from bytes, requires declared/extension agreement, and caps dimensions/pixels before a forced full decode; no provider redirect or durable URL; the digest email carries no attachment URL. - Dropped as deliberate plan boundaries: metadata-light list reads omitting
attachments, and media denials collapsing to a single non-enumeratingnot_found. - Residual risks worth an operator's eye, not filed as findings: media authorization uses the
denormalized
image_attachments.project_idrather than the live parent's project (equivalent today — nothing relocates a page between projects); the bearer purpose token travels as a query parameter, so an external proxy access log would record it (bounded — the token alone grants nothing); a valid image with arbitrary trailing bytes is still stored; draft batches of a removed member survive to the 24h sweep while still consuming that user's caps; Rack buffers the full multipart body before the 20 MB check (bounded in the release image by Thruster'sMAX_REQUEST_BODY);ImageAttachmentOrphanReconciliationJobruns itsGROUP BY/HAVINGaggregate beforeLIMIT 500, so cost scales with total submitted rows;RecurringTasks.schedulere-reads and ERB-evaluatesconfig/recurring.ymlon every/readyprobe. - Untested branches noted but not filed:
MAX_DIMENSION(onlyimage_pixels_too_largeis covered), theRange:branch ofBlobStreaming#stream_blob, and cross-purpose token replay.