Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Boundary rule: if you need an upstream crate in a low-level crate, stop and chec
| `ironclaw_reborn_event_store` | `ironclaw_reborn_event_store/AGENTS.md`, `docs/reborn/contracts/events.md` | Reborn-owned durable event/audit store backends and fixtures. | Product projections, transport fanout, workflow policy. |
| `ironclaw_reborn_traces` | `Cargo.toml`, `src/lib.rs` | Trace Commons / TraceDAO client surface: contribution pipeline, trace client, redaction helpers, conversation-message compatibility, and trace preview re-exports. | Reborn CLI command behavior, LLM provider routing, unredacted trace submission. |
| `ironclaw_memory_native` | `ironclaw_memory_native/AGENTS.md`, `ironclaw_memory_native/CLAUDE.md` | Native filesystem memory provider: `NativeMemoryService`, document repos, chunking, hybrid search, indexer, prompt-write-safety engine. | Provider-neutral memory contracts (`ironclaw_memory`) or product workflow. |
| `ironclaw_attachments` | `Cargo.toml`, `src/lib.rs` | The single inbound-attachment landing routine, writing through project-scoped `ScopedFilesystem` (fail-closed on read-only mounts). | Per-channel persistence paths; text extraction (that's `ironclaw_extractors`). |
| `ironclaw_attachments` | `Cargo.toml`, `src/lib.rs`, `src/ports.rs`, `src/project_scoped.rs`, `src/budgets.rs` | The inbound-attachment **ports** (`InboundAttachmentLander`, `InboundAttachmentReader`, `AttachmentCleanupReport`), the default `ProjectScopedAttachmentLander` over a project-scoped `ScopedFilesystem` (fail-closed on read-only mounts), and the one home for the size ceilings + advertised `AttachmentCapabilities` that WebUI and the OpenAI-compatible adapter read (WS5, PROPOSAL §6.4.9). | Per-channel persistence paths; text extraction (that's `ironclaw_extractors`); the *reader* implementation — `ProjectScopedAttachmentReader` stays in `ironclaw_product` because it also implements a `loops`-tier trait a substrate may not name. |
| `ironclaw_extractors` | `Cargo.toml`, `src/lib.rs` | Pure bytes→text extraction by MIME (PDF/OOXML/legacy Office) with decompression-bomb caps; no I/O. | Network fetches, storage, channel logic. |
| `ironclaw_triggers` | `ironclaw_triggers/AGENTS.md`, `docs/reborn/contracts/triggers.md` | Scheduled-trigger substrate: records, cron/timezone validation, deterministic fire identity, poller core, durable libSQL/Postgres repos, trusted-submit request minting. | Poller lifecycle/composition (composition owns it); any parallel agent loop. |
| `ironclaw_projects` | `ironclaw_projects/CLAUDE.md` | Project entity + membership ACL (live `resolve_access`, never cached) + `ProjectRepository` over `RootFilesystem` with CAS create/delete. **W2 decision: keep standalone; do not fold into composition.** | Product workflow service logic. If revisited, `ironclaw_product` is the only acceptable consumer-side target. |
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -103,11 +103,15 @@ const FROZEN_CONTRACT_NAMES: &[&str] = &[
/// Names that are governed but whose *definition* the one-home half must not
/// police, because an identically-named type legitimately exists elsewhere.
///
/// Four entries, all surfaced by WS1.4 when `channel_adapter`, `external`, and
/// `tool_adapter` arrived from `ironclaw_host_api` — where this scan could not
/// see them, because it governs only the owner crate's own definitions. Each is
/// recorded rather than silently passed, and each names the colliding
/// definition:
/// Two entries. Four arrived with WS1.4, when `channel_adapter`, `external`,
/// and `tool_adapter` came over from `ironclaw_host_api` — where this scan
/// could not see them, because it governs only the owner crate's own
/// definitions. **WS5's `conversations`/`threads` naming-trap row discharged
/// two of them**: `ExternalActorRef` and `ExternalConversationRef` no longer
/// have a second definition, because `ironclaw_conversations` now consumes the
/// canonical pair instead of carrying its own copy, so the carve-out was
/// deleted rather than repointed. What remains is recorded rather than silently
/// passed, and names the colliding definition:
///
/// - `ToolCall` / `ToolResult` — `ironclaw_llm::provider` (`src/provider.rs`).
/// **Not the same concept.** The LLM pair is wire vocabulary: a tool call a
Expand All @@ -117,22 +121,15 @@ const FROZEN_CONTRACT_NAMES: &[&str] = &[
/// serializes". Two genuinely different types that happen to share the
/// obvious English name; renaming either is a §5.1 type-name decision (WS10),
/// not a contracts extraction.
/// - `ExternalActorRef` / `ExternalConversationRef` — `ironclaw_conversations`
/// (`src/ids.rs:48,72`). **The same concept, declared twice**, and this is a
/// real duplicate-surface finding, not a false positive: `conversations::ids`
/// carries a parallel copy of the adapter-identity vocabulary, including
/// `AdapterInstallationId` and `ExternalEventId`, which this walk cannot even
/// see because they are `bounded_string_id!` macro expansions (the same blind
/// spot the module doc records for `LifecyclePackageRef`). Unifying them
/// changes the conversations record grammar — a domain call. Exempted here so
/// the finding is visible and attributable rather than blocking, and so the
/// other two halves of the scan keep their reach.
const COLLISION_EXEMPT: &[&str] = &[
"ExternalActorRef",
"ExternalConversationRef",
"ToolCall",
"ToolResult",
];
///
/// `ironclaw_conversations` still declares `AdapterInstallationId` and
/// `ExternalEventId` beside the canonical pair, and this walk cannot see either:
/// they are `bounded_string_id!` macro expansions, the same blind spot the
/// module doc records for `LifecyclePackageRef`. They are adapter-scoped
/// bounded strings the binding domain owns, not channel refs, so they are a
/// naming overlap rather than a shadow contract — but they are invisible here,
/// which is why the fact is written down instead of assumed.
const COLLISION_EXEMPT: &[&str] = &["ToolCall", "ToolResult"];

/// `macro_rules!` bodies declare types with metavariable names (`pub struct
/// $name(String);` — the `bounded_lifecycle_string!` template in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ const FROZEN_PATH_COUNTS: &[FrozenPathCount] = &[
FrozenPathCount {
category: "test-support",
item_kind: "method",
path: "crates/ironclaw_product/src/scoped_fs/attachment_landing.rs",
path: "crates/ironclaw_product/src/scoped_fs/attachment_reader.rs",
count: 1,
},
FrozenPathCount {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,6 @@ const OPENAI_COMPAT: &str = "ironclaw_reborn_openai_compat";
/// `reborn_dependency_boundaries.rs`).
const PRODUCT_SYMBOLS_WEBUI_STILL_NAMES: &[(&str, &str)] = &[
// --- contract-purity residue ------------------------------------------
(
"ProductAttachmentCapabilities",
"contract-purity: `budgets` is `ironclaw_attachments::AttachmentBudgets`; \
CHECKLIST WS5 `attachments widened … one home for size ceilings` owns the move",
),
(
"product_attachment_capabilities",
"contract-purity: builds ProductAttachmentCapabilities from \
ironclaw_attachments + ironclaw_common::accept_tokens",
),
(
"RebornCreateThreadResponse",
"contract-purity: carries ironclaw_threads::SessionThreadRecord",
Expand Down Expand Up @@ -214,7 +204,12 @@ const PRODUCT_SYMBOLS_OPENAI_COMPAT_STILL_NAMES: &[(&str, &str)] = &[
];

/// Ceilings on the two residues. Only ever move down.
const WEBUI_PRODUCT_SYMBOL_BASELINE: usize = 102;
// 102 when the WS5 transport inversion landed; **100** after the WS5
// `attachments widened` row moved `ProductAttachmentCapabilities` /
// `product_attachment_capabilities` to `ironclaw_attachments` as
// `AttachmentCapabilities` / `attachment_capabilities` — the two symbols this
// list called out by name as that row's to own. Shrink-only.
const WEBUI_PRODUCT_SYMBOL_BASELINE: usize = 100;
const OPENAI_COMPAT_PRODUCT_SYMBOL_BASELINE: usize = 3;

/// Boundary vocabulary this row moved: declared in `ironclaw_product_contracts`
Expand Down
14 changes: 14 additions & 0 deletions crates/ironclaw_attachments/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,30 @@ publish = false
layer = "substrates"

[dependencies]
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
ironclaw_common = { path = "../ironclaw_common", version = "0.4.2" }
ironclaw_extractors = { path = "../ironclaw_extractors", version = "0.1.0" }
ironclaw_filesystem = { path = "../ironclaw_filesystem", version = "0.1.0" }
ironclaw_host_api = { path = "../ironclaw_host_api", version = "0.1.0" }
# The landing/read ports error with `ProductSurfaceError`: their caller is a
# product surface and the WebUI bytes endpoint maps the error code straight onto
# an HTTP status. `ironclaw_product_contracts` is the neutral product-tier
# contract crate and sits in the `contracts` layer, so this is a downward edge
# like `host_api` — never a dependency on `ironclaw_product` itself.
ironclaw_product_contracts = { path = "../ironclaw_product_contracts", version = "0.1.0" }
# `ThreadScope` — the ports are scoped per canonical thread.
ironclaw_threads = { path = "../ironclaw_threads", version = "0.1.0" }
serde = { version = "1", features = ["derive"] }
sha2 = "0.11"
thiserror = "2"
tracing = "0.1"

[dev-dependencies]
ironclaw_filesystem = { path = "../ironclaw_filesystem", version = "0.1.0", features = [
"test-support",
] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

[lints]
Expand Down
71 changes: 71 additions & 0 deletions crates/ironclaw_attachments/src/budgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,81 @@ pub const DEFAULT_ATTACHMENT_BUDGETS: AttachmentBudgets = AttachmentBudgets {
max_total_bytes: 10 * 1024 * 1024,
};

/// Browser-facing inline-attachment contract.
///
/// Carries the `accept` tokens generated from the shared
/// [`ironclaw_common`] format registry (so a file picker can never drift from
/// the server's allowed MIME set) plus the budgets the server-side decode
/// enforces. A surface uses this only for pre-submit hints; the server-side
/// decode remains the sole authority on what is accepted.
///
/// It lives beside [`AttachmentBudgets`] because this crate is the one home for
/// attachment size ceilings (PROPOSAL §6.4.9): a transport that advertises a
/// ceiling and the routine that enforces it must read the same constant.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AttachmentCapabilities {
/// HTML file-input `accept` tokens from the shared registry: exact MIME
/// types plus extensions, e.g. `["image/png", ".png", "application/pdf",
/// ".pdf"]` — never `image/*` wildcards (which would advertise unsupported
/// formats, and which break folder navigation in the native macOS picker).
pub accept: Vec<String>,
/// The count/byte budgets the decode enforces. Flattened, so the wire shape
/// is unchanged and a new budget field reaches the browser without an
/// intermediate edit here.
#[serde(flatten)]
pub budgets: AttachmentBudgets,
}

/// The inline-attachment contract advertised to browsers. Generated from the
/// shared format registry and the budgets the decode enforces, so the picker
/// and the server stay in lockstep by construction.
pub fn attachment_capabilities() -> AttachmentCapabilities {
AttachmentCapabilities {
accept: ironclaw_common::accept_tokens(),
budgets: DEFAULT_ATTACHMENT_BUDGETS,
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn advertised_capabilities_carry_the_enforced_budgets_and_registry_tokens() {
let advertised = attachment_capabilities();
assert_eq!(
advertised.budgets, DEFAULT_ATTACHMENT_BUDGETS,
"the advertised ceiling must be the enforced ceiling"
);
assert_eq!(
advertised.accept,
ironclaw_common::accept_tokens(),
"accept tokens come from the shared registry, never a local list"
);
assert!(
!advertised.accept.iter().any(|token| token.contains('*')),
"wildcards would advertise unsupported formats: {:?}",
advertised.accept
);
}

/// The budgets are `#[serde(flatten)]`ed, so the browser sees one flat
/// object. A nested `budgets` key would silently break every client that
/// reads `max_file_bytes` at the top level.
#[test]
fn advertised_capabilities_serialize_the_budgets_flat() {
let json = serde_json::to_value(attachment_capabilities()).expect("serialize");
let object = json.as_object().expect("object");
assert!(object.contains_key("accept"));
assert!(object.contains_key("max_count"));
assert!(object.contains_key("max_file_bytes"));
assert!(object.contains_key("max_total_bytes"));
assert!(
!object.contains_key("budgets"),
"budgets must stay flattened"
);
}

#[test]
fn default_budgets_match_webui_contract() {
assert_eq!(DEFAULT_ATTACHMENT_BUDGETS.max_count, 10);
Expand Down
8 changes: 7 additions & 1 deletion crates/ironclaw_attachments/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,20 @@
mod budgets;
mod inbound;
mod landing;
mod ports;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Medium — Reconcile guidance with the widened attachments boundary.

The diff makes ironclaw_attachments own and export the ports, default lander, and advertised capabilities, while existing crate-map, product-contracts, and WebUI guidance still describes the previous ownership/dependency boundary. Those instructions conflict with the refactor's new boundary.

Fix: Update the crate map, product-contracts module documentation, and WebUI AGENTS/CLAUDE dependency guidance to describe the new attachment owner and explicitly allow the justified direct WebUI edge.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Two of the three were stale and are fixed; the third was already updated in this PR. 4cd166f727.

Fixed — the crate map. crates/AGENTS.md:87 described ironclaw_attachments as "the single inbound-attachment landing routine" and nothing else, which is the pre-widening boundary. It now names what the crate actually owns after this PR: the ports (InboundAttachmentLander, InboundAttachmentReader, AttachmentCleanupReport), the default ProjectScopedAttachmentLander, and the size ceilings + advertised AttachmentCapabilities that WebUI and the OpenAI-compatible adapter read. The "does not own" column now carries the reader carve-out, since that is the part of the row a reader is most likely to get wrong.

Fixed — the WebUI dependency guidance. crates/ironclaw_webui/AGENTS.md:68-75 enumerated the allowed workspace edges and closed with "any other workspace-crate edge requires an ironclaw_architecture boundary-test update plus explicit PR rationale". ironclaw_attachments was not in that list, so the guidance contradicted the manifest. It is now listed with the justification scoped to what the edge is actually for — the advertised ceilings only, so the transport reads the same constants the landing routine enforces instead of keeping a second copy.

Already correct — the product-contracts module documentation. crates/ironclaw_product_contracts/src/inbound_requests.rs:15-17 already points at the new owner:

decode_attachments and ProductAttachmentCapabilities stay there too, because the byte budgets are ironclaw_attachments types. (CHECKLIST WS5 "attachments widened … one home for size ceilings" owns that move.)

I re-read the crate's other attachment-touching modules (inbound.rs, product_wire.rs, workspace_views.rs, surface.rs) and found no ownership claim that survived the move, so no edit there. If you were pointing at a specific line I have not found, say which and I will take it.

Note this is a different ask from the earlier CodeRabbit thread on Cargo.toml:39, which I refuted: that one named a crates/ironclaw_webui/tests/reborn_dependency_boundaries.rs that does not exist and treated a blocklist as an allowlist. Yours is about the guidance being inconsistent with the new boundary, and it was.

mod project_scoped;

/// Canonical project-workspace mount alias used by attachment landing and
/// scoped file reads.
pub const WORKSPACE_ALIAS: &str = "/workspace";

pub use budgets::{AttachmentBudgets, DEFAULT_ATTACHMENT_BUDGETS};
pub use budgets::{
AttachmentBudgets, AttachmentCapabilities, DEFAULT_ATTACHMENT_BUDGETS, attachment_capabilities,
};
pub use inbound::land_inbound_attachments;
pub use landing::{
ATTACHMENTS_DIR, AttachmentLanding, AttachmentLandingError, DEFAULT_MAX_ATTACHMENT_BYTES,
attachment_batch_scoped_path, attachment_scoped_path, land_attachment,
};
pub use ports::{AttachmentCleanupReport, InboundAttachmentLander, InboundAttachmentReader};
pub use project_scoped::ProjectScopedAttachmentLander;
Loading
Loading