feat(attachments): bridge inbound bytes into transcript AttachmentRefs (#4644) - #4670
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
abbyshekit
left a comment
There was a problem hiding this comment.
Code review — feat(attachments): bridge inbound bytes into transcript AttachmentRefs (#4644)
Multi-agent review (security · bugs · performance · tests · conventions) at 11a8bc62. Diff-only mode.
Review of a draft/WIP PR (review requested) — early feedback; some items may be addressed in later commits of the stack.
2 findings — 1 Medium, 1 Low. Posted as a comment (advisory). Confidence ≥ 50, deduplicated across reviewers.
| Sev | Conf | Reviewer | Location | Finding |
|---|---|---|---|---|
| Medium | 80% | tests | inbound.rs:73 |
No test for partial failure mid-batch (succeed-then-fail across the Vec) |
| Low | 65% | tests | inbound.rs:200 |
Fallback-filename path (filename = None) is never exercised through the bridge |
Generated by near-ai-code-review (5 parallel reviewer agents + intent analysis). Diff-only; confidence ≥ 50; ≤ 15 inline comments.
| message_id, | ||
| index, | ||
| filename: filename.as_deref(), | ||
| fallback_extension: &fallback_extension, |
There was a problem hiding this comment.
[Medium · 80% confidence] No test for partial failure mid-batch (succeed-then-fail across the Vec)
tests reviewer
land_inbound_attachments lands attachments in a loop and early-returns on the first failure via land_attachment(...).await?. When attachment[0] lands successfully and attachment[N>0] fails, the half-built refs vector is dropped and the whole call returns Err — so the bytes for att-0 are physically landed in the filesystem but NO AttachmentRef is ever returned for them (dangling bytes / silent data loss from the caller's perspective). The doc comment asserts exactly this behavior ("On the first failure the whole batch returns the error; partial writes that may already have landed are left in place"), but no test exercises it. The only failure test, fails_closed_on_read_only_project_mount, uses a SINGLE attachment on a fully read-only mount, so every write fails on item 0 — it never reaches the succeed-then-fail boundary that the focus area is about. A per-item failure is constructible against InMemoryBackend (e.g. pre-seed a conflicting entry to force FilesystemError::IndexConflict on the second write, as in in_memory.rs conflicting kind on same name fails), so this is testable. The test should assert the return is Err AND document the dangling-bytes/no-ref outcome (or, if the intent is all-or-nothing with rollback, assert the landed-then-failed bytes were cleaned up).
Fix: Add tests::inbound::partial_failure_drops_already_landed_refs covering a multi-attachment batch where item 0 lands and item 1 fails (force via a pre-seeded conflicting entry / IndexConflict on item 1's path): assert the call returns Err and assert the observable state of item 0's already-landed bytes (present-but-unreferenced, documenting the data-loss boundary, or cleaned up if rollback is intended). Add a mirror case with the FAILING item first to confirm no ref is produced when nothing succeeds.
#[tokio::test]
async fn returns_err_and_drops_already_landed_refs_when_a_later_item_fails() {
let backend = Arc::new(InMemoryBackend::new());
// Seed a conflicting entry at att-1's resolved path so its write fails
// (IndexConflict) while att-0 lands cleanly.
let writer = project_mount(Arc::clone(&backend), MountPermissions::read_write());
let err = land_inbound_attachments(&writer, &test_scope(), DEFAULT_PROJECT_MOUNT_ALIAS,
"2026-06-09", "msg1", vec![
inbound("att-0", AttachmentKind::Document, "text/plain", "a.txt", b"ok"),
inbound("att-1", AttachmentKind::Document, "text/plain", "b.txt", b"boom"),
]).await.expect_err("a later landing failure fails the whole batch");
assert!(matches!(err, AttachmentLandingError::Write(_)));
// Document the data-loss boundary: att-0 bytes landed but no ref returned.
}There was a problem hiding this comment.
Addressed on this branch (commit 559a1c46f). later_item_failure_fails_the_batch_and_leaves_earlier_bytes_landed forces att-1's write to fail (by seeding a child under its computed path so the backend rejects writing a file where a directory exists), lands att-0 first, and asserts: the batch returns Err(Write(_)), and att-0's bytes remain addressable at its storage path — documenting exactly the dangling-bytes / no-ref boundary the rustdoc promises (all-or-nothing rollback is explicitly not the contract; a retry re-lands at the same deterministic paths). The pre-existing fails_closed_on_read_only_project_mount covers the fail-on-item-0 case.
| ); | ||
|
|
||
| // The bytes are addressable at each ref's storage_key through the same | ||
| // authority — a reader resolves the recorded ScopedPath with no extra |
There was a problem hiding this comment.
[Low · 65% confidence] Fallback-filename path (filename = None) is never exercised through the bridge
tests reviewer
InboundAttachment carries fallback_extension, used only when filename is None to synthesize the landed filename. The inbound() test helper always sets filename: Some(...), so every inbound.rs test lands a named file; the fallback_extension field of land_inbound_attachments is never exercised. The synthesis logic itself is unit-tested in landing.rs (scoped_path_synthesizes_filename_when_absent), but the bridge that wires InboundAttachment.fallback_extension -> AttachmentLanding.fallback_extension -> storage_key is not — a swapped/dropped field assignment in the InboundAttachment destructuring would compile and pass all current tests. Confirm the synthesized filename flows into AttachmentRef.storage_key and that AttachmentRef.filename stays None.
Fix: Add tests::inbound::lands_with_synthesized_filename_when_filename_absent: build an InboundAttachment with filename=None and fallback_extension="png" at a known index, then assert storage_key ends with msg1-{index}-attachment-{index+1}.png and that the returned AttachmentRef.filename is None.
#[tokio::test]
async fn lands_with_synthesized_filename_when_filename_absent() {
let backend = Arc::new(InMemoryBackend::new());
let writer = project_mount(backend, MountPermissions::read_write());
let att = InboundAttachment { id: "att-0".into(), kind: AttachmentKind::Image,
mime_type: "image/png".into(), filename: None,
fallback_extension: "png".into(), bytes: vec![0x89,0x50] };
let refs = land_inbound_attachments(&writer, &test_scope(),
DEFAULT_PROJECT_MOUNT_ALIAS, "2026-06-09", "msg1", vec![att])
.await.expect("lands");
assert_eq!(refs[0].storage_key.as_deref(),
Some("/workspace/attachments/2026-06-09/msg1-0-attachment-1.png"));
assert!(refs[0].filename.is_none());
}There was a problem hiding this comment.
Addressed on this branch (commit 559a1c46f). lands_with_synthesized_filename_when_filename_absent builds an InboundAttachment with filename: None and asserts the returned storage_key ends with msg1-1-attachment.png (1-based index; png derived from image/png via the registry) and that AttachmentRef.filename stays None — exercising the InboundAttachment.fallback_extension → AttachmentLanding.fallback_extension → storage_key wiring the named-file tests never reach.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughConsolidates AttachmentRef into ironclaw_common and introduces ironclaw_attachments::inbound::land_inbound_attachments to persist inbound bytes into AttachmentRef entries with storage_key and size metadata. No CLAUDE.md/.claude/AGENTS.md invariants appear violated. ChangesAttachment Type Consolidation and Inbound Landing
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
068a7d0 to
9105e29
Compare
a6b08c7 to
e67f7e8
Compare
9105e29 to
5249ebc
Compare
e67f7e8 to
af3acc6
Compare
#4644) Connects Track 6 landing to Track 2's transcript contract: the unit the byte-bearing ingress layer calls to turn raw inbound attachments into the durable AttachmentRefs the Reborn transcript persists. - New land_inbound_attachments(filesystem, scope, project_alias, date, message_id, Vec<InboundAttachment>) -> Vec<AttachmentRef>: lands each attachment's bytes through the project-scoped filesystem authority and builds the AttachmentRef with storage_key set to the landed ScopedPath and size_bytes set to the landed byte count. extracted_text is left None (extraction/transcription is a later pipeline stage). Read-only project mounts fail closed. - InboundAttachment input carries id/kind/mime/filename/fallback_extension + raw bytes. kind reuses ironclaw_common::AttachmentKind (re-exported by ironclaw_threads), so the whole pipeline speaks one kind vocabulary. - attachment_scoped_path now folds the per-attachment index into the path ({message_id}-{index}-{filename}) so multiple attachments on one message land at distinct paths even when they share a filename. The single-file Track 6 primitive did not need this; the batch bridge does. Why here and not at inbound_turn.rs: the product-workflow inbound point has only bytes-free ProductAttachmentDescriptors and no filesystem handle, so land_attachment cannot run there. The byte-bearing layer is the ingress; this is the reusable unit it calls. Wiring the ingress upload path to call this bridge (and thread a project-scoped ScopedFilesystem with the caller's scope) is the next slice. Adds dep ironclaw_attachments -> ironclaw_threads (no cycle: threads does not depend on attachments). Tests: batch landing sets storage_key/size/kind/mime/filename on each ref with extracted_text None and the bytes addressable at each storage_key via the same authority; same-filename attachments land at distinct paths; read-only project mount fails closed.
AttachmentRef is a plain serde DTO with no transcript-specific behavior, and the attachment vocabulary (AttachmentKind, IncomingAttachment) already lives in ironclaw_common. Co-locate AttachmentRef there so ironclaw_attachments depends on the leaf common crate instead of taking an upward edge on the ironclaw_threads transcript-contract crate to construct it. - Move the AttachmentRef struct from ironclaw_threads::contract to ironclaw_common::attachment, next to IncomingAttachment, with a doc cross-link noting it is the durable, byte-free projection. - ironclaw_threads re-exports AttachmentRef from common (mirrors the existing AttachmentKind re-export); the public path ironclaw_threads::AttachmentRef is unchanged, so no consumer edits. - ironclaw_attachments now depends on ironclaw_common, not ironclaw_threads; land_inbound_attachments still returns Vec<AttachmentRef>. ironclaw_attachments stays a clean leaf (filesystem + host_api + common).
The bridge sits one use away from the ironclaw_common attachment format
registry (kind_for_mime / canonical_extension), so derive kind and the
fallback filename extension from mime_type inside land_inbound_attachments
rather than requiring every caller to populate them. This drops two
redundant fields from InboundAttachment ({id, mime_type, filename, bytes})
and makes a ref's kind always consistent with its mime_type by
construction — a caller can no longer pass kind=Image with an
application/pdf mime.
…aths Addresses the two review comments on the bridge: - lands_with_synthesized_filename_when_filename_absent exercises the filename=None path end to end (synthesized name from index + registry-derived extension; ref.filename stays None) — never reached by the named-file tests. - later_item_failure_fails_the_batch_and_leaves_earlier_bytes_landed forces a mid-batch write failure (seeding a child so att-1's path is a directory) and documents the rustdoc'd boundary: Err is returned and att-0's already-landed bytes remain addressable with no ref.
…idge land_attachment gained a required max_bytes bound (Track 6). Propagate it through land_inbound_attachments so the inbound bridge caps every attachment it lands and an over-limit item fails the batch with TooLarge before any write. Test: an oversized item in a batch returns TooLarge and lands nothing.
af3acc6 to
8b005f8
Compare
There was a problem hiding this comment.
Pull request overview
This PR bridges inbound attachment bytes into durable transcript AttachmentRefs by introducing a reusable batch landing routine in a new leaf crate (ironclaw_attachments) and moving AttachmentRef into ironclaw_common (while keeping ironclaw_threads::AttachmentRef available via re-export).
Changes:
- Add
ironclaw_attachments::land_inbound_attachmentsto land each attachment throughScopedFilesystemand buildAttachmentRefwithstorage_key+size_bytes. - Move
AttachmentRefdefinition toironclaw_commonand re-export it fromironclaw_threadsto preserve the primary public path. - Update attachment landing path construction to include the per-attachment index to avoid same-filename collisions.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| crates/ironclaw_threads/src/lib.rs | Switches AttachmentRef exposure to a re-export from ironclaw_common. |
| crates/ironclaw_threads/src/contract.rs | Removes the local AttachmentRef definition and consumes ironclaw_common::AttachmentRef in the transcript contract. |
| crates/ironclaw_common/src/lib.rs | Re-exports AttachmentRef and attachment-format registry helpers at the crate root. |
| crates/ironclaw_common/src/attachment.rs | Introduces AttachmentRef as the durable, byte-free transcript projection type. |
| crates/ironclaw_attachments/src/lib.rs | New crate root exporting landing + inbound bridge modules. |
| crates/ironclaw_attachments/src/landing.rs | Implements path sanitization, scoped-path construction, and land_attachment write-through-authority routine with tests. |
| crates/ironclaw_attachments/src/inbound.rs | Implements InboundAttachment + land_inbound_attachments bridge producing AttachmentRefs, with tests for collision avoidance and fail-closed mounts. |
| crates/ironclaw_attachments/Cargo.toml | Defines the new leaf crate and its dependencies. |
| Cargo.toml | Adds crates/ironclaw_attachments to the workspace members list. |
| Cargo.lock | Records the new crate in the lockfile. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// The attachment `kind` and the fallback filename extension are *derived from* | ||
| /// `mime_type` against the [`ironclaw_common`] attachment format registry — the | ||
| /// authoritative source — so callers cannot drift them out of sync with the | ||
| /// MIME type they pass. | ||
| #[derive(Debug, Clone)] |
There was a problem hiding this comment.
Fixed the PR description. InboundAttachment carries id/mime_type/filename + raw bytes; the kind and fallback extension are derived from mime_type via the ironclaw_common registry (so they cannot drift from the MIME type), not supplied as input fields. Description now matches the actual shape.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/ironclaw_attachments/src/inbound.rs`:
- Around line 52-65: The function land_inbound_attachments currently only
enforces per-item max_bytes and must also enforce hard limits on batch
cardinality and aggregate bytes to prevent unbounded memory/storage use: before
iterating, check attachments.len() against a new constant (e.g. MAX_BATCH_ITEMS)
and compute the sum of each inbound size (use the field carrying byte length
inside InboundAttachment) and ensure total <= a new MAX_TOTAL_BYTES (or a
provided parameter), returning an AttachmentLandingError variant (add one if
needed) when either limit is exceeded; keep the existing per-item max_bytes
check inside the loop but short-circuit earlier on batch/aggregate violations to
avoid allocating refs or starting work unnecessarily.
In `@crates/ironclaw_common/src/attachment.rs`:
- Around line 97-99: The AttachmentRef struct's id field currently uses a raw
String; replace it with a validated newtype (e.g., AttachmentId) or justify
keeping String in a doc comment. Create an AttachmentId newtype (tuple struct or
opaque type) with parsing/validation (FromStr/TryFrom<&str>) and update
AttachmentRef to pub id: AttachmentId, adjusting any constructors, serializers
(Serialize/Deserialize) and usages to convert between String and AttachmentId;
alternatively, if the ID is truly opaque and unvalidated, add a clear doc
comment on AttachmentRef::id explaining that channel-provided opaque IDs require
no validation and thus String is intentionally used. Ensure all references to
AttachmentRef::id and any serde impls are updated accordingly.
- Around line 102-104: The field pub mime_type: String on the Attachment struct
is accepting arbitrary strings despite the doc stating it’s validated; introduce
a MimeType newtype (e.g., struct MimeType(String)) with a TryFrom<String>
(and/or FromStr) impl that performs the attachment format registry
lookup/validation and returns an error on mismatch, change the
Attachment.mime_type type to MimeType, and update all call sites/constructors to
use MimeType::try_from (or str::parse) so the validation is enforced at
construction time; also add unit tests for MimeType::try_from and adjust any
serialization/deserialization impls (serde) to map through the newtype.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 0e1655ea-f30d-40c9-8618-4c5ffad1c57d
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock,!**/Cargo.lock
📒 Files selected for processing (9)
Cargo.tomlcrates/ironclaw_attachments/Cargo.tomlcrates/ironclaw_attachments/src/inbound.rscrates/ironclaw_attachments/src/landing.rscrates/ironclaw_attachments/src/lib.rscrates/ironclaw_common/src/attachment.rscrates/ironclaw_common/src/lib.rscrates/ironclaw_threads/src/contract.rscrates/ironclaw_threads/src/lib.rs
There was a problem hiding this comment.
♻️ Duplicate comments (1)
crates/ironclaw_attachments/src/inbound.rs (1)
52-104:⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy liftBatch cardinality and aggregate bytes unbounded.
attachments.len()and total bytes across the batch are uncapped. Per guidelines: "User-controlled inputs must not grow unbounded; apply hard size limits (entries + total bytes)." A caller can exhaust memory/storage in one request despite per-itemmax_bytes.Source: Coding guidelines
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: f7102a2b-f172-453d-a7ee-69d36a948b99
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock,!**/Cargo.lock
📒 Files selected for processing (7)
crates/ironclaw_attachments/Cargo.tomlcrates/ironclaw_attachments/src/inbound.rscrates/ironclaw_attachments/src/lib.rscrates/ironclaw_common/src/attachment.rscrates/ironclaw_common/src/lib.rscrates/ironclaw_threads/src/contract.rscrates/ironclaw_threads/src/lib.rs
Review nits flagged the id/mime_type fields as raw String against the typed- internals rule. Document the intent instead of adding ceremony newtypes: id is an opaque channel token whose only contract (per-message uniqueness) is already enforced by validate_attachment_refs, and mime_type is stored verbatim because the format registry is a recognizer, not an allowlist — so a registry-gating newtype would wrongly reject well-formed but unregistered MIME types. Replaces the misleading "validated upstream" wording.
| let mut refs = Vec::with_capacity(attachments.len()); | ||
| for (index, attachment) in attachments.into_iter().enumerate() { | ||
| let InboundAttachment { |
| /// Stable identifier for this attachment within its message. An opaque, | ||
| /// channel-provided token with no format contract (unique only within its | ||
| /// message, which `validate_attachment_refs` enforces), so it stays a | ||
| /// boundary `String` rather than a validated newtype. |
land_inbound_attachments gained a required max_bytes bound (Track 6/#4670). The ProjectScopedAttachmentLander now owns that policy — set to DEFAULT_MAX_ATTACHMENT_BYTES — and passes it per landing. The send_message route's 14 MiB body cap is the primary gate; this is defense in depth so a single attachment can never land unbounded bytes.
… path (#4644) (#4672) * feat(reborn): accept inline attachment uploads on the WebChat v2 send path (#4644) End-to-end ingress wiring for #4644: a browser can now attach files to a WebChat v2 message, the bytes land in project storage, and the user message persists attachment references. This is the call site for the land_inbound_attachments bridge. Flow (no src/ changes): - DTO: WebUiSendMessageRequest gains `attachments: Vec<WebUiInboundAttachment>` (mime_type, filename, base64). decode_attachments() validates MIME against the shared format registry (Track 1), decodes base64, and enforces the v1 budgets (5 MiB/file, 10 MiB total, 10 files max). Kept separate from into_command so the serializable command never carries raw bytes. - Facade: RebornServices::submit_turn decodes attachments, lands them through a new InboundAttachmentLander port (using the stable per-message external_event_id for the storage path), and builds MessageContent::with_attachments before accept_inbound_message. With no lander wired, an attachment-bearing message is rejected (503) rather than silently dropped. - Port impl: composition's ProjectScopedAttachmentLander writes through the project-scoped workspace ScopedFilesystem (the same authority the agent's file tools resolve through) via land_inbound_attachments, and is wired onto the facade in build_webui_services when a local runtime is present. - Body limit: the send_message route descriptor goes from 1 MiB to 14 MiB to carry base64 of the 10 MiB decoded cap; the descriptor/body-limit contract tests and the composition CLAUDE.md are updated to match. Tests: - decode_attachments: metadata/kind/bytes, MIME normalization, unsupported MIME, malformed base64, per-file/total oversize, too-many, empty. - ProjectScopedAttachmentLander: lands + returns ref with storage_key; read-only workspace mount maps to an internal error. - Facade (test through the caller): submit_turn lands the attachment and the accepted user message carries the ref with storage_key; attachments without a wired lander are rejected with ServiceUnavailable. New dep edges product_workflow/composition -> ironclaw_attachments (accepted by the reborn dependency-boundary test). Deferred: model-visibility (content_parts / extracted_text / project_path), the memory index note, and the static frontend "+ button" upload UI. * refactor(reborn): route attachment-landing errors through canonical constructors Reuse the error type's own constructors instead of open-coding status/kind: - submit_turn's no-lander path now calls RebornServicesError::service_unavailable(false) instead of an inline from_status_kind(Unavailable, ServiceUnavailable, 503, false). - Add a public RebornServicesError::internal() so host-composition adapters stop hand-rolling the full struct literal (which silently drifts if a field is added); ProjectScopedAttachmentLander uses it. - The lander no longer discards the underlying AttachmentLandingError: it logs it (warn) before mapping to the sanitized 500, so an operator can tell a misconfigured read-only mount from a write failure. - decode_attachments derives the per-attachment id from enumerate() rather than the decoded-accumulator length. * feat(reborn): cap inbound attachment size at the WebChat v2 lander land_inbound_attachments gained a required max_bytes bound (Track 6/#4670). The ProjectScopedAttachmentLander now owns that policy — set to DEFAULT_MAX_ATTACHMENT_BYTES — and passes it per landing. The send_message route's 14 MiB body cap is the primary gate; this is defense in depth so a single attachment can never land unbounded bytes. * refactor(reborn): use canonical mime normalizer; fix landing-path comment Address review: - Replace the local normalize_attachment_mime in webui_inbound with ironclaw_common::normalize_mime_type (identical logic, but the single canonical normalizer the registry/kind/transcription already share) so attachment MIME handling can't drift from the rest of the workspace. - Correct the attachment-landing comment in reborn_services: the external_event_id is the path's stable message segment, but the lander partitions by UTC day, so a retry crossing midnight UTC lands under a new directory (dangling earlier bytes). Idempotency is enforced at message acceptance, not by the storage path.
nearai#4644) (nearai#4670) * feat(attachments): bridge inbound bytes into transcript AttachmentRefs (nearai#4644) Connects Track 6 landing to Track 2's transcript contract: the unit the byte-bearing ingress layer calls to turn raw inbound attachments into the durable AttachmentRefs the Reborn transcript persists. - New land_inbound_attachments(filesystem, scope, project_alias, date, message_id, Vec<InboundAttachment>) -> Vec<AttachmentRef>: lands each attachment's bytes through the project-scoped filesystem authority and builds the AttachmentRef with storage_key set to the landed ScopedPath and size_bytes set to the landed byte count. extracted_text is left None (extraction/transcription is a later pipeline stage). Read-only project mounts fail closed. - InboundAttachment input carries id/kind/mime/filename/fallback_extension + raw bytes. kind reuses ironclaw_common::AttachmentKind (re-exported by ironclaw_threads), so the whole pipeline speaks one kind vocabulary. - attachment_scoped_path now folds the per-attachment index into the path ({message_id}-{index}-{filename}) so multiple attachments on one message land at distinct paths even when they share a filename. The single-file Track 6 primitive did not need this; the batch bridge does. Why here and not at inbound_turn.rs: the product-workflow inbound point has only bytes-free ProductAttachmentDescriptors and no filesystem handle, so land_attachment cannot run there. The byte-bearing layer is the ingress; this is the reusable unit it calls. Wiring the ingress upload path to call this bridge (and thread a project-scoped ScopedFilesystem with the caller's scope) is the next slice. Adds dep ironclaw_attachments -> ironclaw_threads (no cycle: threads does not depend on attachments). Tests: batch landing sets storage_key/size/kind/mime/filename on each ref with extracted_text None and the bytes addressable at each storage_key via the same authority; same-filename attachments land at distinct paths; read-only project mount fails closed. * refactor(attachments): move AttachmentRef to ironclaw_common AttachmentRef is a plain serde DTO with no transcript-specific behavior, and the attachment vocabulary (AttachmentKind, IncomingAttachment) already lives in ironclaw_common. Co-locate AttachmentRef there so ironclaw_attachments depends on the leaf common crate instead of taking an upward edge on the ironclaw_threads transcript-contract crate to construct it. - Move the AttachmentRef struct from ironclaw_threads::contract to ironclaw_common::attachment, next to IncomingAttachment, with a doc cross-link noting it is the durable, byte-free projection. - ironclaw_threads re-exports AttachmentRef from common (mirrors the existing AttachmentKind re-export); the public path ironclaw_threads::AttachmentRef is unchanged, so no consumer edits. - ironclaw_attachments now depends on ironclaw_common, not ironclaw_threads; land_inbound_attachments still returns Vec<AttachmentRef>. ironclaw_attachments stays a clean leaf (filesystem + host_api + common). * refactor(attachments): derive InboundAttachment kind/extension from mime The bridge sits one use away from the ironclaw_common attachment format registry (kind_for_mime / canonical_extension), so derive kind and the fallback filename extension from mime_type inside land_inbound_attachments rather than requiring every caller to populate them. This drops two redundant fields from InboundAttachment ({id, mime_type, filename, bytes}) and makes a ref's kind always consistent with its mime_type by construction — a caller can no longer pass kind=Image with an application/pdf mime. * test(attachments): cover synthesized-filename and mid-batch-failure paths Addresses the two review comments on the bridge: - lands_with_synthesized_filename_when_filename_absent exercises the filename=None path end to end (synthesized name from index + registry-derived extension; ref.filename stays None) — never reached by the named-file tests. - later_item_failure_fails_the_batch_and_leaves_earlier_bytes_landed forces a mid-batch write failure (seeding a child so att-1's path is a directory) and documents the rustdoc'd boundary: Err is returned and att-0's already-landed bytes remain addressable with no ref. * feat(attachments): thread the landing size cap through the inbound bridge land_attachment gained a required max_bytes bound (Track 6). Propagate it through land_inbound_attachments so the inbound bridge caps every attachment it lands and an over-limit item fails the batch with TooLarge before any write. Test: an oversized item in a batch returns TooLarge and lands nothing. * docs(common): clarify AttachmentRef.id/mime_type invariants Review nits flagged the id/mime_type fields as raw String against the typed- internals rule. Document the intent instead of adding ceremony newtypes: id is an opaque channel token whose only contract (per-message uniqueness) is already enforced by validate_attachment_refs, and mime_type is stored verbatim because the format registry is a recognizer, not an allowlist — so a registry-gating newtype would wrongly reject well-formed but unregistered MIME types. Replaces the misleading "validated upstream" wording.
… path (nearai#4644) (nearai#4672) * feat(reborn): accept inline attachment uploads on the WebChat v2 send path (nearai#4644) End-to-end ingress wiring for nearai#4644: a browser can now attach files to a WebChat v2 message, the bytes land in project storage, and the user message persists attachment references. This is the call site for the land_inbound_attachments bridge. Flow (no src/ changes): - DTO: WebUiSendMessageRequest gains `attachments: Vec<WebUiInboundAttachment>` (mime_type, filename, base64). decode_attachments() validates MIME against the shared format registry (Track 1), decodes base64, and enforces the v1 budgets (5 MiB/file, 10 MiB total, 10 files max). Kept separate from into_command so the serializable command never carries raw bytes. - Facade: RebornServices::submit_turn decodes attachments, lands them through a new InboundAttachmentLander port (using the stable per-message external_event_id for the storage path), and builds MessageContent::with_attachments before accept_inbound_message. With no lander wired, an attachment-bearing message is rejected (503) rather than silently dropped. - Port impl: composition's ProjectScopedAttachmentLander writes through the project-scoped workspace ScopedFilesystem (the same authority the agent's file tools resolve through) via land_inbound_attachments, and is wired onto the facade in build_webui_services when a local runtime is present. - Body limit: the send_message route descriptor goes from 1 MiB to 14 MiB to carry base64 of the 10 MiB decoded cap; the descriptor/body-limit contract tests and the composition CLAUDE.md are updated to match. Tests: - decode_attachments: metadata/kind/bytes, MIME normalization, unsupported MIME, malformed base64, per-file/total oversize, too-many, empty. - ProjectScopedAttachmentLander: lands + returns ref with storage_key; read-only workspace mount maps to an internal error. - Facade (test through the caller): submit_turn lands the attachment and the accepted user message carries the ref with storage_key; attachments without a wired lander are rejected with ServiceUnavailable. New dep edges product_workflow/composition -> ironclaw_attachments (accepted by the reborn dependency-boundary test). Deferred: model-visibility (content_parts / extracted_text / project_path), the memory index note, and the static frontend "+ button" upload UI. * refactor(reborn): route attachment-landing errors through canonical constructors Reuse the error type's own constructors instead of open-coding status/kind: - submit_turn's no-lander path now calls RebornServicesError::service_unavailable(false) instead of an inline from_status_kind(Unavailable, ServiceUnavailable, 503, false). - Add a public RebornServicesError::internal() so host-composition adapters stop hand-rolling the full struct literal (which silently drifts if a field is added); ProjectScopedAttachmentLander uses it. - The lander no longer discards the underlying AttachmentLandingError: it logs it (warn) before mapping to the sanitized 500, so an operator can tell a misconfigured read-only mount from a write failure. - decode_attachments derives the per-attachment id from enumerate() rather than the decoded-accumulator length. * feat(reborn): cap inbound attachment size at the WebChat v2 lander land_inbound_attachments gained a required max_bytes bound (Track 6/nearai#4670). The ProjectScopedAttachmentLander now owns that policy — set to DEFAULT_MAX_ATTACHMENT_BYTES — and passes it per landing. The send_message route's 14 MiB body cap is the primary gate; this is defense in depth so a single attachment can never land unbounded bytes. * refactor(reborn): use canonical mime normalizer; fix landing-path comment Address review: - Replace the local normalize_attachment_mime in webui_inbound with ironclaw_common::normalize_mime_type (identical logic, but the single canonical normalizer the registry/kind/transcription already share) so attachment MIME handling can't drift from the rest of the workspace. - Correct the attachment-landing comment in reborn_services: the external_event_id is the path's stable message segment, but the lander partitions by UTC day, so a retry crossing midnight UTC lands under a new directory (dangling earlier bytes). Idempotency is enforced at message acceptance, not by the storage path.
Summary
Connects Track 6 (byte landing, #4668) to Track 2 (transcript
AttachmentRef, #4655): the reusable unit the byte-bearing ingress layer calls to turn raw inbound attachments into the durableAttachmentRefs the Reborn transcript persists — withstorage_keyset to the landedScopedPath. This is the "connectland_attachment→AttachmentRef.storage_key" step.Scope:
crates/only, nosrc/changes.What changed
land_inbound_attachments(filesystem, scope, project_alias, date, message_id, Vec<InboundAttachment>, max_bytes) -> Vec<AttachmentRef>— lands each attachment's bytes through the project-scoped filesystem authority and builds theAttachmentRefwithstorage_key = <landed ScopedPath>andsize_bytes = <landed byte count>. Each item is bounded bymax_bytes(over-limit fails the batch withTooLargebefore any write);extracted_textis leftNone(extraction/transcription is a later pipeline stage). Read-only project mounts fail closed.InboundAttachmentinput carriesid/mime_type/filename+ rawbytes. The attachmentkindand fallback extension are derived frommime_typethrough theironclaw_commonformat registry (the authoritative source), so a caller cannot drift them out of sync with the MIME type.kindreusesironclaw_common::AttachmentKind(re-exported byironclaw_threads), so the whole pipeline speaks one kind vocabulary.attachment_scoped_pathnow folds the per-attachment index into the path ({message_id}-{index}-{filename}) so multiple attachments on one message land at distinct paths even when they share a filename. The single-file Track 6 primitive didn't need this; the batch bridge does. (Track 6's three path tests are updated accordingly.)AttachmentRefnow lives inironclaw_common(next toAttachmentKind/IncomingAttachment), withironclaw_threadsre-exporting it — the public pathironclaw_threads::AttachmentRefis unchanged. So the bridge's only new dep edge isironclaw_attachments → ironclaw_common(a leaf crate), not an upward edge on theironclaw_threadstranscript-contract crate.ironclaw_attachmentsstays a clean leaf (filesystem+host_api+common).Why here, not at
inbound_turn.rsI traced the product-workflow inbound point (
inbound_turn.rs:376, whereMessageContent::text(payload.text)is built): it has only bytes-freeProductAttachmentDescriptors and no filesystem handle (ironclaw_product_workflowdoesn't depend onironclaw_filesystem), soland_attachmentcannot run there. The byte-bearing layer is the ingress; this is the reusable unit it calls. Wiring the ingress upload path to invoke this bridge — and thread a project-scopedScopedFilesystemwith the authenticated caller's scope — is the next slice (host-runtime plumbing).Tests
6 bridge tests (16 total in the crate, all passing): batch landing sets
storage_key/size/kind/mime/filenameon each ref withextracted_textNoneand the bytes addressable at eachstorage_keyvia the same authority; same-filename attachments land at distinct paths;filename: Nonelands a synthesized name and keeps the ref'sfilenameNone; a later-item failure fails the batch and leaves earlier bytes landed (no ref); an over-max_bytesitem fails the batch withTooLargeand lands nothing; read-only project mount fails closed.Stacking
Based on
fix/4644-track6-attachment-landing(#4668). Part of the #4644 epic stack (registry → transcript refs → byte landing → this bridge).Next slices
InboundAttachments, obtain a project-scopedScopedFilesystemfor the caller, call this bridge, and submit the turn withMessageContent::with_attachments(text, refs).storage_key→ChatMessage.content_parts(images); run extraction/transcription to fillextracted_text; emit model-facingproject_path; fixchat_workflow.rs's[non_text_content]drop.store_attachment_dataconvergence.Summary by CodeRabbit
New Features
Refactor