Skip to content

feat(1.AF P1+P2): engine media plumbing — MediaStore + deInlineMedia choke point + ADR-0042/0043/0044#33

Merged
cemililik merged 13 commits into
mainfrom
development
Jun 18, 2026
Merged

feat(1.AF P1+P2): engine media plumbing — MediaStore + deInlineMedia choke point + ADR-0042/0043/0044#33
cemililik merged 13 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

What this is

Workstream 1.AF — Engine media plumbing (Phase C of the 1.m6 multimodal sub-spine), the P1 + P2 slice: the three design ADRs (0042/0043/0044) plus the foundations and the I3 keystone. It wires behavior onto the ADR-0031 media seam shape (frozen at 1.AD) and the media-input adapters (1.AE).

This is not all of 1.AF. P3 (egress/failover/SSRF) and P4 (read_media/byte-delivery/sweep/cost) are intentionally deferred (see Deferred below). 1.AF stays in progress after this merges — the roadmap matrix row stays until P3+P4 land (Done-after-merge). This PR also folds in a small 1.AE Done-doc follow-up (bb1a7b1).

Design ADRs (accepted)

  • [ADR-0042] — the MediaStore host port on ExecutionHost, the deInlineMedia choke-point ordering, and the media_objects/media_references retention store + terminal sweep (amends ADR-0036).
  • [ADR-0043] — the binary media-egress capability, the FallbackChainMediaStore re-materialization (B5), and the SSRF mechanism half (amends ADR-0031; resolves its sidecar-ownership self-contradiction).
  • [ADR-0044]read_media scope-set authz, the byte-delivery gate, the save_to write port, and the per-modality media cost (amends ADR-0028 + ADR-0029).

In-place > Amended notes added to ADR-0028/0029/0031/0036; deferred-tasks.md reconciled.

What landed (P1 + P2)

P1 foundations (2538733): deInlineMedia (cycle-safe flight→durable transform + a pure base64 decoder); per-modality capability gating — one shared mediaSupportReason predicate behind both the FallbackChain pre-skip and the adapter gate (coarse-vision removed); strict output_modalities/save_to node fields; OpenAI audio_tokensmediaUnits.

P1 storage (3c501e1): the media_objects + media_references tables + migration 0002 (refcount junction + the run/node-vs-session/workspace scope-kind keying); the FilesystemMediaStore CAS + InMemoryMediaStore.

P2 — the I3 keystone (1b012c5): MediaStore injected (optional, absent-tolerant) on ExecutionHost; deInlineMedia runs at the one #emitDurable choke point — before #bus.next stamps/validates and before persist/deliver — so the numbered, persisted, delivered event is handle-only, with the gap-free sequenceNumber + persist-before-deliver ordering preserved.

Multi-dimensional review → fixes folded in

An unconstrained adversarial review (independent reviewers → per-finding verification → consolidation) and two external review passes were run. The review caught two confirmed HIGH defects on the production path that block-quality demanded fixing, plus the lower-priority items — all addressed in 0977130 / 60f6dac / e639717:

  • HIGH — I3 byte leak: non-canonical byte carriers (a data: URI string, a loose {kind:'base64'} source, a raw buffer) bypassed rewrite() and persisted. → deInlineMedia now hard-fails on every non-canonical carrier (+ unknown source kind / modality) — it can only emit handles + text.
  • HIGH — terminal not total: any non-media_store_unavailable de-inline throw (e.g. a store.put rejection) on a media-bearing terminal escaped the catch-less #loop → hang + unhandled rejection. → the terminal catch now strips the best-effort media payload on any de-inline failure (re-throws only for non-terminal drafts), so the run always settles.
  • MEDIUM — url passthrough: a url-only payload was skipped by the byte-only fast-path. → containsDurableUnsafeMedia (bytes + url media part) is used by the fast-path + the no-store guard; an un-re-hosted url now hard-fails.
  • Canonical-home blockers: database-schema.md gains the two media tables; workflow-yaml-spec.md gains output_modalities/save_to + the run.id namespace; sse-event-schema.md gains cost:updated.mediaUnits.
  • Lows: CAS sha256 integrity-on-read; InMemoryMediaStore copy-on-put/get; media_objects.handle is a UNIQUE constraint (Postgres FK parity); save_to UNC rejection; misleading put comment fixed; ADR-0044 audio-unit reconciliation.

New regression tests cover each fix (non-canonical-carrier hard-fail, data-URI-with-store → run:failed with no leak, rejecting store.put → exactly one terminal/no hang, decodeBase64, the ADR-0042 §2 reconcile media-free backstop).

Invariants upheld

  • I3 — no media bytes (base64/url) cross a durable / run-event / log / DB / exported-YAML / IPC / checkpoint boundary; the durable form is always a content-addressed handle (now an active transform at the one choke point + hard-fail on anything non-handle-able).
  • Engine purity (packages/core has zero node:*/DOM/Tauri imports — MediaStore is a host-injected port), seam purity (no vendor type crosses @relavium/llm), strict authored YAML, derived-from-run_events checkpoints (media_objects is a separate retention store, not a checkpoint table), and secrets never in logs/events/IPC.

Deferred to P3 + P4 (not in this PR)

  • P3 (egress/failover): D8 adapter handle/url resolution, D7 the B5 sidecar, D9 the binary media-egress capability + the SSRF mechanism half (security-critical).
  • P4 (access/gc/cost): D12/D13 read_media + the byte-delivery gate (security-critical), D11 the terminal sweep, D15 the output_modalities load-check, D16 the save_to write port, D17 the per-modality media cost governor, the keychain-bridge IPC test.
  • D9 and D12/D13 each get a dedicated security-review pass when they land (per ADR-0043/0044); the 1.AF acceptance checks gate on those PRs.

Validation

pnpm turbo run lint typecheck test build16/16 green (shared 358, llm 334, db 39, core 738). Leakwatch-clean on every changed file. All commits GPG-signed.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added persistent, content-addressed media storage with refcount-based retention
    • De-inline media at durable boundaries so delivered/persisted outputs use safe handles
    • Introduced a separate media-units billing axis (including OpenAI audio token accounting) and surfaced it in cost:updated SSE events
    • Expanded workflow YAML with {{ run.id }}, output_modalities, and validated save_to
    • Added media access/spend governance with read_media and save_to, plus media egress hardening
  • Bug Fixes
    • Prevented base64/media leakage across terminal settlement, resume, and crash reconciliation
  • Documentation
    • Updated roadmap and ADRs/specs covering media storage, egress, and access governance

cemililik and others added 9 commits June 18, 2026 13:24
PR #32 (media-input adapters + the shared SSRF policy primitive) is
merged, so flip 1.AE from ◇ to ✅ Done everywhere it is tracked:

- phase-1-engine-and-llm.md: §1.AE bullet header + a "Landed (PR #32)"
  sub-bullet (what shipped vs. what is deferred to 1.AF) + matrix row.
- current.md: the live-status line + the 1.m6 sub-spine callout.
- CLAUDE.md: both status paragraphs.
- README.md: the public status line.

The SSRF *mechanism* half (host DNS-resolve + connect-by-validated-IP +
per-hop redirect), per-modality FallbackChain gating, mediaUnits, recorded
media-in conformance fixtures, and handle/url resolution stay deferred to
1.AF (already recorded in deferred-tasks.md). 1.AF-1.AH remain on 1.m6.

Refs: ADR-0031, ADR-0037

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the three genuinely-new 1.AF decisions, each amending an existing
ADR (append-only):
- ADR-0042: the MediaStore host port on ExecutionHost, the deInlineMedia
  choke-point ordering, and the media_objects/media_references retention
  store + terminal-state sweep (amends ADR-0036).
- ADR-0043: the bytes-shaped media-egress capability, the FallbackChain<->
  MediaStore re-materialization (B5) resolving ADR-0031's sidecar-ownership
  wording, and the SSRF mechanism half (amends ADR-0031).
- ADR-0044: read_media scope-set authz + byte-delivery gate, the save_to
  write port, and the per-modality media cost folded into the existing
  budget cap (amends ADR-0028 and ADR-0029).

Add the in-place Amended notes to ADR-0036/0031/0028/0029, index the three
ADRs, and reconcile deferred-tasks.md (the SSRF mechanism half lands at
1.AF for the media path; point the media_objects/A6/retention/workspace
items at the new ADRs).

Refs: ADR-0042, ADR-0043, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ating, node fields, mediaUnits

Phase-1 foundations for 1.AF (engine media plumbing), each independently green:

- D3 deInlineMedia (@relavium/shared): the flight->durable transform — a
  cycle-safe, non-mutating deep-walk replacing every in-flight base64 media
  part with a content-addressed handle via the injected MediaStore and
  setting the durable byteLength (no-media fast path; url re-host is the D9
  engine step). Adds a pure platform-free base64 decoder.
- D5/D6 per-modality capability gating (@relavium/llm): ONE shared
  mediaSupportReason predicate behind BOTH the FallbackChain pre-skip
  (supportsRequest) and the adapter-entry assertMediaCapabilities, so a
  provider incapable of the requested MODALITY is skipped with a specific
  reason — never coarse-vision, never silently flattened. assertSupported
  stays tools-only so the adapter's schema-first ZodError order holds.
- D14 node fields (@relavium/shared): strict output_modalities (agent) +
  save_to (output); document/empty and absolute/traversal paths rejected.
- D18 mediaUnits (@relavium/llm): OpenAI audio_tokens -> a disjoint
  mediaUnits entry reporting the raw count (no fabricated seconds).

Refs: ADR-0031, ADR-0042, ADR-0043, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…les + MediaStore CAS

- D10 (@relavium/db): the media_objects (12th) + media_references (13th)
  Drizzle tables + migration 0002 + the 0002 DDL fidelity snapshot. The
  refcount derives from the per-distinct-reference junction (UNIQUE
  (handle,scope_kind,scope_id)); scope_kind is a CHECK-constrained superset —
  run/node are refcount+sweep lifetime rows, session/workspace are ALSO the
  read_media authz Scope kinds (authz consults session/workspace only). The
  FIRST persisted mutable state outside the ADR-0003 derived model — a
  retention store, not a checkpoint store. The content-addressed
  media://sha256 handle is the integrity hash (no checksum).
- D1 (@relavium/db): FilesystemMediaStore (content-addressed CAS, sharded,
  fail-closed path jail) + InMemoryMediaStore reference — Node-side
  (node:crypto + node:fs), host-injected into ExecutionHost.mediaStore; the
  pure engine never imports them.
- MEDIA_SCOPE_KINDS / MEDIA_AUTHZ_SCOPE_KINDS (@relavium/shared) — the
  junction superset + the authz-granting subset (ADR-0042/0044).

Refs: ADR-0042, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the #emitDurable choke point

- D2: optional, absent-tolerant `mediaStore?` on the ExecutionHost seam
  (ADR-0042 §1) + threaded through createInMemoryHost. A text-only host
  leaves it undefined and never enters a byte path.
- D4: deInlineMedia runs at the ONE emit/persist choke point (ADR-0042 §2)
  — BEFORE #bus.next stamps + validates + before persist/deliver, so the
  numbered, persisted, delivered event is handle-only (I3). The single
  added await; the synchronous seq-assign / persist-before-deliver /
  #deliveryTail ordering is unchanged; a no-media draft pays only a cheap
  scan. Secret masking already happened upstream, so de-inline is the sole
  emit-time transform here.
- Missing-store guard: a media-bearing draft with no MediaStore throws
  media_store_unavailable (never a silent byte leak). Non-terminal events
  surface to the #onOutcome / #begin backstops -> a single run:failed; a
  TERMINAL event strips its best-effort outputs/partialOutputs to empty so
  the run still settles (exactly-one-terminal-event is sacred), never
  blocking the terminal or leaking.

Tests: media node output de-inlines to a handle (no base64 in the delivered
or persisted log, gap-free seq); a media run with no store fails cleanly.

Refs: ADR-0042

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…+P4 remaining)

Not a Done mark (Done-after-merge): record where 1.AF stands so the
branch state is unambiguous. §1.AF gains an in-progress sub-bullet listing
the landed P1 (D1/D3/D5/D6/D10/D14/D18) + P2 keystone (D2/D4 — deInlineMedia
at the #emitDurable choke point) and the remaining P3/P4 (D7/D8/D9 egress +
D11/D12/D13/D15/D16/D17 access/gc/cost), flagging D9 + D12/D13 as
security-critical (dedicated security-review pass). current.md's live-status
callout mirrors it. The matrix row stays ◇ until the 1.AF PR merges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ects (review HIGH #1/#2)

A multi-dimensional adversarial review of P1+P2 confirmed two HIGH defects on
the production (store-injected) de-inline path that the prior reviews missed:

- I3 BYTE LEAK (HIGH #1): deInlineMedia's rewrite only handled the canonical
  {type:'media'} part, so a non-canonical byte carrier in an opaque z.unknown()
  run-event position — a base64 data: URI string, a loose {kind:'base64'}
  source, a raw binary buffer — passed THROUGH and persisted/delivered (the
  with-store branch had no re-scan; the run-event positions have no byte
  backstop). Fix: deInlineMedia now HARD-FAILS on every non-canonical carrier
  (data: URI / loose base64 / raw buffer) and on an unknown source kind /
  unknown modality — it can only ever emit handles + text, never pass bytes.

- TERMINAL NOT TOTAL (HIGH #2): #emitDurable only rescued the
  media_store_unavailable throw on a terminal; a store.put rejection (disk full)
  or any other de-inline throw on a media-bearing terminal escaped the
  catch-less #loop → no terminal, consumer hang, unhandled rejection. Fix: the
  terminal catch now strips the best-effort media payload on ANY de-inline
  failure (re-throws only for non-terminal drafts), so the run always settles.

- url MEDIUM: add containsDurableUnsafeMedia (the byte scan + a url media part),
  used by the de-inline fast-path + the no-store guard, so a url-only payload
  is never silently passed through (it now hard-fails pending the D9 re-host).

Regression tests: non-canonical carriers + url-only hard-fail (no leak/put);
a data: URI node output fails the run with no bytes persisted even WITH a store;
a rejecting store.put yields exactly one terminal (no hang); decodeBase64 +
containsDurableUnsafeMedia direct unit tests; the ADR-0042 §2 reconcile
media-free backstop. Also fixes the stale #emitDurable ordering comment.

Refs: ADR-0042, ADR-0043

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Postgres parity, save_to UNC

- FilesystemMediaStore.get verifies the content-address (sha256) on read — a
  corrupted/tampered file is caught, not propagated (review A#8).
- InMemoryMediaStore copies on put AND get — a caller mutation cannot corrupt
  the stored blob (match the filesystem copy-on-read).
- media_objects.handle is a UNIQUE CONSTRAINT (.unique()), not just a unique
  index — a Postgres FK target needs a constraint, so the media_references FK
  keeps SQLite<->Postgres parity (ADR-0005). Regenerated migration 0002.
- Correct the misleading FilesystemMediaStore.put comment (mimeType is NOT yet
  recorded anywhere — the P3/P4 lifecycle wiring D10/D11 will; nothing does at
  P1+P2) (review B HIGH #4).
- SaveToSchema also rejects a leading backslash / UNC path; tests cover the
  C:/ drive form and the UNC form (review LOW).

Refs: ADR-0042, ADR-0005

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… fidelity)

Close the two canonical-home merge-blockers the review flagged, plus the
doc-fidelity nits:

- database-schema.md: add the media_objects + media_references tables (§Media
  tables) — columns, the UNIQUE-constraint handle, the partial GC index, the
  FK CASCADE, the refcount<->authz scope_kind keying, and the 7-day grace
  window — the ADR-0042 canonical-home requirement (B1).
- workflow-yaml-spec.md: document output_modalities (agent) + save_to (output)
  + the run.id interpolation namespace — the ADR-0044 §2 mandate (B2).
- sse-event-schema.md: add the disjoint mediaUnits field to cost:updated.
- deferred-tasks.md: fix the stale OpenAI audio 'unit: second' -> 'unit: count'
  (audio_tokens are tokens, not seconds).
- ADR-0044 §3: reconcile the audio unit (token-based providers report a raw
  count, priced per-count / observability-only) so the realized fold switches
  on MediaUnitsEntry.unit.
- ADR-0043/0044: add a scope note that they describe the full 1.AF target
  (P3/P4 mechanisms land in later PRs, each with its own security-review).

Refs: ADR-0042, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sorry @cemililik, your pull request is larger than the review limit of 150000 diff characters

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@cemililik, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 1 hour, 31 minutes, and 31 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more credits in the billing tab to continue.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 054541e8-795c-4a48-8655-9770db7860af

📥 Commits

Reviewing files that changed from the base of the PR and between 7325ec3 and 2b0fa52.

⛔ Files ignored due to path filters (1)
  • packages/db/src/__snapshots__/client.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (42)
  • CLAUDE.md
  • README.md
  • docs/decisions/0028-workflow-resource-governance.md
  • docs/decisions/0029-tool-policy-hardening.md
  • docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md
  • docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md
  • docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md
  • docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md
  • docs/decisions/0044-media-access-governance-read-media-save-to-cost.md
  • docs/decisions/README.md
  • docs/reference/contracts/sse-event-schema.md
  • docs/reference/contracts/workflow-yaml-spec.md
  • docs/reference/desktop/database-schema.md
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/phase-1-engine-and-llm.md
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/engine/engine.ts
  • packages/core/src/engine/execution-host.ts
  • packages/core/src/engine/invariant-error.ts
  • packages/db/drizzle/0002_round_umar.sql
  • packages/db/drizzle/meta/0002_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/client.test.ts
  • packages/db/src/index.ts
  • packages/db/src/media-store.test.ts
  • packages/db/src/media-store.ts
  • packages/db/src/schema.ts
  • packages/llm/src/adapters/openai.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/adapters/shared.ts
  • packages/llm/src/capabilities.test.ts
  • packages/llm/src/capabilities.ts
  • packages/llm/src/fallback-chain.ts
  • packages/shared/src/constants.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts
  • packages/shared/src/index.ts
  • packages/shared/src/media-deinline.test.ts
  • packages/shared/src/media-deinline.ts
  • packages/shared/src/node.test.ts
  • packages/shared/src/node.ts
📝 Walkthrough

Walkthrough

PR #33 delivers the 1.AF media engine plumbing milestone: shared content helpers for detecting and transforming inline media payloads (decodeBase64, containsDurableUnsafeMedia, deInlineMedia), two MediaStore implementations (filesystem CAS and in-memory), a Drizzle DB retention schema (media_objects/media_references), per-modality LLM capability gating, OpenAI audio mediaUnits accounting, output_modalities/save_to node schema additions, and de-inline integration at the engine's #emitDurable choke point — accompanied by ADRs 0042–0044 and updated roadmap/reference documentation.

Changes

1.AF Media Engine Plumbing

Layer / File(s) Summary
Shared content helpers: decodeBase64, containsDurableUnsafeMedia, deInlineMedia
packages/shared/src/content.ts, packages/shared/src/content.test.ts, packages/shared/src/constants.ts, packages/shared/src/media-deinline.ts, packages/shared/src/media-deinline.test.ts, packages/shared/src/index.ts
Adds decodeBase64 (RFC 4648 lookup decoder), smuggling-detection exports (isBase64DataUri, isBinaryBuffer, isCanonicalBase64Source, containsDurableUnsafeMedia), and MEDIA_SCOPE_KINDS/MEDIA_AUTHZ_SCOPE_KINDS constants. Introduces deInlineMedia — a non-mutating, cycle-safe graph rewrite that converts in-flight canonical base64 media parts to durable handle-only parts via a MediaStore, with fail-closed guards rejecting data URIs, raw buffers, unre-hosted URL sources, loose base64 objects, and unknown MIME types. Tests cover all success and failure paths.
Node schema: OutputModalitiesSchema and SaveToSchema
packages/shared/src/node.ts, packages/shared/src/node.test.ts
Adds OutputModalitiesSchema (non-empty OUTPUT_MODALITIES array, rejecting document) and SaveToSchema (relative-path-only, rejecting absolute/Windows/UNC/.. paths), wired into AgentNodeSchema.output_modalities and OutputNodeSchema.save_to. Tests validate acceptance and rejection rules.
MediaStore implementations and DB retention schema
packages/db/src/media-store.ts, packages/db/src/media-store.test.ts, packages/db/src/schema.ts, packages/db/drizzle/0002_round_umar.sql, packages/db/drizzle/meta/0002_snapshot.json, packages/db/drizzle/meta/_journal.json, packages/db/src/index.ts, packages/db/src/client.test.ts
Adds FilesystemMediaStore (SHA-256 CAS, sharded paths, integrity re-verification) and InMemoryMediaStore (defensive copy, handle validation). Adds media_objects and media_references Drizzle tables with modality/scope-kind CHECK constraints, cascade FK, GC partial index, and row types. Generates migration SQL, Drizzle snapshot, and journal entry. Tests cover store operations, handle validation, and DB constraint enforcement (uniqueness, FK, cascade delete).
LLM per-modality capability gating refactor
packages/llm/src/capabilities.ts, packages/llm/src/capabilities.test.ts, packages/llm/src/adapters/shared.ts, packages/llm/src/fallback-chain.ts, packages/llm/src/adapters/openai.ts, packages/llm/src/adapters/openai.test.ts
Adds mediaSupportReason() (per-modality input/output gate traversing messages and tool results) and requestSupportReason() to capabilities.ts; removes coarse vision requirement from requiredCapabilities(); rewires assertMediaCapabilities in shared.ts to delegate to mediaSupportReason; updates FallbackChain.#skipReason to use requestSupportReason with a detailed message; adds mapUsage audio token accounting (raw audio_tokensmediaUnits array, disjoint from token counts) to the OpenAI adapter.
Engine #emitDurable de-inline choke point
packages/core/src/engine/execution-host.ts, packages/core/src/engine/invariant-error.ts, packages/core/src/engine/engine.ts, packages/core/src/engine/engine.test.ts
Extends ExecutionHost with mediaStore?: MediaStore and createInMemoryHost to accept it. Adds media_store_unavailable to RunLoopInvariantCode. Wires #deInlineDraft (calls deInlineMedia when store is present, throws RunLoopInvariantError when absent and media is present) and stripTerminalMediaPayload into #emitDurable so every persisted/delivered event is handle-only. Terminal de-inline failures strip outputs instead of hanging. Tests verify base64→handle conversion, no-store failure, data-URI smuggling rejection, store rejection termination, gap-free sequence numbers, and crash-reconcile media-free guarantee.
ADRs 0042–0044, amendments, and reference/roadmap docs
docs/decisions/0042-...md, docs/decisions/0043-...md, docs/decisions/0044-...md, docs/decisions/00{28,29,31,36}-*.md, docs/decisions/README.md, docs/reference/contracts/sse-event-schema.md, docs/reference/contracts/workflow-yaml-spec.md, docs/reference/desktop/database-schema.md, docs/roadmap/*, CLAUDE.md, README.md
Introduces ADR-0042 (mediaStore port, deInlineMedia ordering, retention/GC), ADR-0043 (media egress, SSRF mechanism, FallbackChain re-materialization), and ADR-0044 (read_media authz, save_to write port, per-modality media cost). Adds amendment cross-references to ADRs 0028, 0029, 0031, and 0036. Updates ADR index, SSE cost:updated contract (mediaUnits?), workflow YAML spec (output_modalities, save_to, {{ run.id }}), desktop DB schema docs (media tables section), and roadmap/status documents.

Sequence Diagram(s)

sequenceDiagram
  actor RunLoop
  participant emitDurable as engine.#emitDurable
  participant deInlineDraft as engine.#deInlineDraft
  participant deInlineMedia as deInlineMedia (shared)
  participant MediaStore
  participant RunEventBus

  RunLoop->>emitDurable: draft event (may contain base64 media)
  emitDurable->>deInlineDraft: draft
  alt host.mediaStore present and media detected
    deInlineDraft->>deInlineMedia: draft payload, store
    deInlineMedia->>MediaStore: put(bytes) → handle
    deInlineMedia-->>deInlineDraft: handle-only draft
    deInlineDraft-->>emitDurable: handle-only draft
    emitDurable->>RunEventBus: seq assign → persist → deliver (handle-only)
  else no mediaStore + durable-unsafe media
    deInlineDraft-->>emitDurable: throws RunLoopInvariantError(media_store_unavailable)
    note over emitDurable: non-terminal → rethrow (run failure backstop)<br/>terminal → stripTerminalMediaPayload → deliver stripped event
  else no media (fast path)
    deInlineDraft-->>emitDurable: draft unchanged
    emitDurable->>RunEventBus: seq assign → persist → deliver
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • HodeTech/Relavium#11: Introduced the ADR-0031 multimodal seam shape and ContentPart/DurableContentPart types that deInlineMedia and the engine choke point directly consume.
  • HodeTech/Relavium#17: Introduced WorkflowEngine.#emitDurable and the persist-before-deliver run-loop substrate (ADR-0036) that this PR extends with the #deInlineDraft media pass.
  • HodeTech/Relavium#32: Landed the 1.AE media-input adapters and shared SSRF policy primitive; this PR's capability gating refactor (mediaSupportReason, assertMediaCapabilities delegation) directly builds on and replaces the modality-gating seam introduced there.

Poem

🐇 A rabbit hops through bytes tonight,
no base64 shall leak from sight!
Handles only cross the gate,
SHA-256 seals every crate.
The engine choke-point holds the line —
Phase Two is unblocked, and all is fine!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: implementation of 1.AF Phase 1 and Phase 2 engine media plumbing with MediaStore, deInlineMedia transform, and three accepted ADRs.
Docstring Coverage ✅ Passed Docstring coverage is 88.57% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch development

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request implements the engine media plumbing (1.AF) for the multimodal sub-spine, introducing content-addressed media storage (MediaStore), database tables for media retention and references, and the deInlineMedia transform at the emit choke point. It also refines LLM capability gating to be per-modality and updates the workflow YAML schemas. Feedback includes addressing a path-resolution bug in FilesystemMediaStore when the root directory is the filesystem root, and hardening a media array check in the capability gating to prevent potential TypeErrors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +93 to +98
#pathFor(digest: string): string {
const full = resolve(this.#root, join(digest.slice(0, 2), digest.slice(2)));
if (full !== this.#root && !full.startsWith(this.#root + sep)) {
throw new Error('media path escapes the store root');
}
return full;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

When the rootDir is the filesystem root (e.g., / on POSIX or C:\ on Windows), resolve(rootDir) returns / or C:\. Appending sep results in // or C:\\. Since a resolved path like /ab/cd or C:\ab\cd does not start with // or C:\\, the check fails and throws a false-positive 'escapes the store root' error. Ensuring safeRoot is correctly formatted resolves this issue.

  #pathFor(digest: string): string {
    const full = resolve(this.#root, join(digest.slice(0, 2), digest.slice(2)));
    const safeRoot = this.#root.endsWith(sep) ? this.#root : this.#root + sep;
    if (full !== this.#root && !full.startsWith(safeRoot)) {
      throw new Error('media path escapes the store root');
    }
    return full;
  }

Comment thread packages/llm/src/capabilities.ts Outdated
const reason = mediaInputReason(inputCaps, part.mimeType);
if (reason !== null) return reason;
}
if (part.type === 'tool_result' && part.media !== undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Using part.media !== undefined is not fully safe if part.media can be null (e.g., from parsed JSON). If part.media is null, the for...of loop will throw a TypeError. Using Array.isArray(part.media) is a safer and more robust guard before iterating.

Suggested change
if (part.type === 'tool_result' && part.media !== undefined) {
if (part.type === 'tool_result' && Array.isArray(part.media)) {

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🧹 Nitpick comments (1)
packages/llm/src/capabilities.ts (1)

43-60: 💤 Low value

Consider extracting message traversal to reduce cognitive complexity.

SonarCloud flags cognitive complexity 23 > 15. The nested loops are structurally necessary, but extracting a messageMediaReason helper would localize the per-message logic and bring complexity within bounds.

♻️ Optional refactor
+function messageMediaReason(
+  inputCaps: CapabilityFlags['media']['input'],
+  message: LlmRequest['messages'][number],
+): string | null {
+  for (const part of message.content) {
+    if (part.type === 'media') {
+      const reason = mediaInputReason(inputCaps, part.mimeType);
+      if (reason !== null) return reason;
+    }
+    if (part.type === 'tool_result' && part.media !== undefined) {
+      for (const mediaPart of part.media) {
+        const reason = mediaInputReason(inputCaps, mediaPart.mimeType, ' in tool_result');
+        if (reason !== null) return reason;
+      }
+    }
+  }
+  return null;
+}
+
 export function mediaSupportReason(supports: CapabilityFlags, req: LlmRequest): string | null {
   const inputCaps = supports.media.input;
   for (const message of req.messages) {
-    for (const part of message.content) {
-      if (part.type === 'media') {
-        const reason = mediaInputReason(inputCaps, part.mimeType);
-        if (reason !== null) return reason;
-      }
-      if (part.type === 'tool_result' && part.media !== undefined) {
-        for (const mediaPart of part.media) {
-          const reason = mediaInputReason(inputCaps, mediaPart.mimeType, ' in tool_result');
-          if (reason !== null) return reason;
-        }
-      }
-    }
+    const reason = messageMediaReason(inputCaps, message);
+    if (reason !== null) return reason;
   }
   return outputCombinationReason(supports, req.outputModalities);
 }
🤖 Prompt for 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.

In `@packages/llm/src/capabilities.ts` around lines 43 - 60, The
mediaSupportReason function has excessive cognitive complexity due to nested
loops iterating through message parts and checking for media types. Extract the
inner loop logic that processes a single message's content (the loop over
message.content and the nested logic for checking media parts and tool_result
media) into a separate helper function named messageMediaReason. This helper
should accept a single message and the inputCaps parameter, then return the
first media reason found or null. Update the main mediaSupportReason function to
call this new helper for each message in the loop, replacing the current nested
iteration logic.

Source: Linters/SAST tools

🤖 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 `@CLAUDE.md`:
- Line 41: The status text in the CLAUDE.md file indicates that per-modality
capability gating is deferred to 1.AF, but this PR is 1.AF and implements that
feature via mediaSupportReason and requestSupportReason. Update the status
paragraph where it currently states the per-modality gating is deferred to 1.AF
to instead indicate that 1.AF is landing with this PR and includes the
per-modality capability gating implementation using mediaSupportReason and
requestSupportReason, making clear that this capability is now complete rather
than deferred.

In `@docs/decisions/0028-workflow-resource-governance.md`:
- Line 7: Remove the in-place amendment note starting with "Amended 2026-06-18
by [ADR-0044]..." from line 7 of ADR-0028, as ADRs should remain immutable after
acceptance. Instead, keep the original ADR-0028 content unchanged and ensure
that ADR-0044 is properly structured as a superseding ADR that references
ADR-0028 and documents the refinement relationship between them through
cross-links rather than editing the accepted decision file itself.

In
`@docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md`:
- Around line 44-52: The section describing media_objects and media_references
tables (lines 44-52) contains too many concrete DDL-level details like column
names, constraints, and migration specifics that belong in the canonical schema
reference documentation. Trim this section to focus on the architectural
decision rationale: why you need the UNIQUE handle, why media_references is a
junction table with the UNIQUE (handle, scope_kind, scope_id) constraint, and
why the terminal-state sweep with grace window is necessary. Remove the detailed
descriptions of columns, constraints, defaults, and migration format (0002_*
pattern, byte-for-byte snapshot test), and instead add explicit links to
docs/reference/desktop/database-schema.md and docs/reference/ where those
concrete specifications live. Preserve the explanation of how scope_kind serves
dual roles (refcount versus authz), the separation from ADR-0003 derived model,
and the three-step lifecycle ordering, but strip the implementation artifact
restatement to avoid drift between the ADR and canonical reference docs.

In `@docs/decisions/0044-media-access-governance-read-media-save-to-cost.md`:
- Around line 45-50: The ADR currently contains detailed contract specifications
(PreEgressHook shape, MEDIA_BILLED_MODALITIES behavior, per-model media rate
pricing mechanics, estimate calculation fold logic, and realized cost tracking)
in lines 45-50 that should be canonicalized in reference documentation rather
than restated in the decision record. Extract these concrete semantic details
about hook shapes, modality/unit handling, and pricing fold mechanics from the
current content and move them to the appropriate file(s) in
docs/reference/contracts/ (likely alongside config-spec.md and other contract
references). Replace the detailed lines 45-50 in this ADR with a concise summary
of the decision rationale and links to the canonical reference documentation,
keeping only the high-level decision points and avoiding duplication of contract
truth.

In `@docs/reference/contracts/workflow-yaml-spec.md`:
- Around line 185-188: The blockquote containing the retry documentation
(starting with "> **`retry` on non-agent nodes.**") has blank lines inserted
within it between lines 185 and 188, which violates the MD028 markdown linting
rule. Remove any blank lines that appear inside this blockquote to maintain
continuity, ensuring the entire retry explanation remains as a single unbroken
blockquote without internal line breaks.

In `@docs/reference/desktop/database-schema.md`:
- Around line 453-456: The markdown linting error MD028 is triggered by a blank
line within the blockquote section. To fix this, add a `>` marker to the blank
line that appears between the two blockquote paragraphs (the line between the
first blockquote ending with "never grants read." and the second blockquote
starting with "**Retention / GC"). This will properly continue the blockquote
without an internal blank line break.

In `@packages/core/src/engine/engine.ts`:
- Around line 1401-1414: The non-terminal de-inline failure rethrow on line 1413
does not account for resumed runs where human_gate:resumed is emitted before
`#schedule`() is called. If de-inline throws during the emit (due to media-like
payloads in gate input), the gate is mutated but `#schedule`() never executes,
leaving the run without terminal settlement. Ensure that de-inline errors during
resume() branches are caught and handled to allow `#schedule`() to complete,
either by sanitizing payloads before emit or by wrapping the error handling to
guarantee terminal event settlement even when de-inline fails on non-terminal
events in resumed contexts.
- Around line 1456-1460: The unsafe cast `as RunEventDraft` in the
`#deInlineDraft` method violates strict typing because `deInlineMedia` returns
`Promise<unknown>`. Instead of using the bare cast at the call site, create a
type-safe wrapper function in `engine.ts` (such as `deInlineDraftSafely`) that
takes the draft and mediaStore as parameters and returns
`Promise<RunEventDraft>`, then move the cast inside this wrapper function. This
encapsulates the type assertion and makes the intent explicit while keeping the
call site clean and type-safe.

In `@packages/db/drizzle/0002_round_umar.sql`:
- Around line 1-28: The migration artifacts for the `@relavium/db` package are out
of sync with the schema definition in packages/db/src/schema.ts. Run the command
pnpm --filter `@relavium/db` db:generate to regenerate the migration artifacts,
which will update the files 0002_round_umar.sql, meta/0002_snapshot.json, and
meta/_journal.json to match the current schema definition. After regeneration,
commit all updated artifact files to resolve the schema drift.

In `@packages/db/src/client.test.ts`:
- Around line 276-281: In the test file at the
client.db.insert(mediaReferences).values() call, remove the unsafe type cast `as
'run'` from the scopeKind property where it is set to 'bogus'. Instead, add a
`@ts-expect-error` comment before the line containing the .values() method to
intentionally disable type checking for this invalid literal, which properly
documents that this test is deliberately passing an invalid value to verify
constraint checking behavior.

In `@packages/db/src/media-store.ts`:
- Around line 64-70: The `#write` method currently writes directly to the final
content-addressed path using writeFile, which risks persisting a partially
written blob if the process is interrupted. To fix this, modify the method to
first write the bytes to a temporary file in the same directory as the final
destination, then use an atomic rename or move operation to publish the
temporary file to the final content-addressed path. This ensures that the final
path only contains a complete, valid blob or remains absent if the write is
interrupted, maintaining integrity for subsequent get() calls.

---

Nitpick comments:
In `@packages/llm/src/capabilities.ts`:
- Around line 43-60: The mediaSupportReason function has excessive cognitive
complexity due to nested loops iterating through message parts and checking for
media types. Extract the inner loop logic that processes a single message's
content (the loop over message.content and the nested logic for checking media
parts and tool_result media) into a separate helper function named
messageMediaReason. This helper should accept a single message and the inputCaps
parameter, then return the first media reason found or null. Update the main
mediaSupportReason function to call this new helper for each message in the
loop, replacing the current nested iteration logic.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9fc33cea-d520-4909-aa83-c78cff98ad96

📥 Commits

Reviewing files that changed from the base of the PR and between 7325ec3 and e639717.

⛔ Files ignored due to path filters (1)
  • packages/db/src/__snapshots__/client.test.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (42)
  • CLAUDE.md
  • README.md
  • docs/decisions/0028-workflow-resource-governance.md
  • docs/decisions/0029-tool-policy-hardening.md
  • docs/decisions/0031-llm-seam-shape-amendment-multimodal-io.md
  • docs/decisions/0036-run-loop-substrate-event-bus-and-execution-host.md
  • docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md
  • docs/decisions/0043-media-egress-failover-rematerialization-ssrf.md
  • docs/decisions/0044-media-access-governance-read-media-save-to-cost.md
  • docs/decisions/README.md
  • docs/reference/contracts/sse-event-schema.md
  • docs/reference/contracts/workflow-yaml-spec.md
  • docs/reference/desktop/database-schema.md
  • docs/roadmap/current.md
  • docs/roadmap/deferred-tasks.md
  • docs/roadmap/phases/phase-1-engine-and-llm.md
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/engine/engine.ts
  • packages/core/src/engine/execution-host.ts
  • packages/core/src/engine/invariant-error.ts
  • packages/db/drizzle/0002_round_umar.sql
  • packages/db/drizzle/meta/0002_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/client.test.ts
  • packages/db/src/index.ts
  • packages/db/src/media-store.test.ts
  • packages/db/src/media-store.ts
  • packages/db/src/schema.ts
  • packages/llm/src/adapters/openai.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/adapters/shared.ts
  • packages/llm/src/capabilities.test.ts
  • packages/llm/src/capabilities.ts
  • packages/llm/src/fallback-chain.ts
  • packages/shared/src/constants.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts
  • packages/shared/src/index.ts
  • packages/shared/src/media-deinline.test.ts
  • packages/shared/src/media-deinline.ts
  • packages/shared/src/node.test.ts
  • packages/shared/src/node.ts

Comment thread CLAUDE.md Outdated
- **Date**: 2026-06-05
- **Related**: [0008-local-first-phase-1-cloud-phase-2.md](0008-local-first-phase-1-cloud-phase-2.md), [0014-managed-metering-quota-and-billing.md](0014-managed-metering-quota-and-billing.md), [../reference/contracts/workflow-yaml-spec.md](../reference/contracts/workflow-yaml-spec.md), [../reference/contracts/sse-event-schema.md](../reference/contracts/sse-event-schema.md), [../reference/contracts/ipc-contract.md](../reference/contracts/ipc-contract.md), [../architecture/execution-model.md](../architecture/execution-model.md)

> **Amended 2026-06-18 by [ADR-0044](0044-media-access-governance-read-media-save-to-cost.md).** A refinement, not a reversal: ADR-0044 adds a **disjoint per-modality media cost class** to this ADR's pre-egress governor — it widens the pre-egress hook to carry `outputModalities`/a media-unit estimate and folds the media estimate into the **existing** `max_cost_microcents` cap (no new cap dimension, no new event/error class). This ADR's budget / timeout / concurrency decisions are unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Prefer superseding ADRs over in-place amendment edits to accepted ADR files.

Line 7 introduces an in-file amendment note on ADR-0028. Under the repository ADR rule, accepted decisions should remain immutable and be refined via a superseding/new ADR plus cross-links, not by editing prior ADR content.

As per coding guidelines, docs/decisions/*.md requires ADRs to be append-only with “supersede, never rewrite.”

🤖 Prompt for 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.

In `@docs/decisions/0028-workflow-resource-governance.md` at line 7, Remove the
in-place amendment note starting with "Amended 2026-06-18 by [ADR-0044]..." from
line 7 of ADR-0028, as ADRs should remain immutable after acceptance. Instead,
keep the original ADR-0028 content unchanged and ensure that ADR-0044 is
properly structured as a superseding ADR that references ADR-0028 and documents
the refinement relationship between them through cross-links rather than editing
the accepted decision file itself.

Source: Coding guidelines

Comment on lines +45 to +50
- **Widen `PreEgressHook`** from `{ model, maxTokens? }` to also carry `outputModalities?: OutputModality[]` and `mediaUnitsEstimate?: { modality: MediaBilledModality; units: number }[]`; the `AgentRunner`/turn loop populates them from the node's `output_modalities` (unit counts default from `[defaults].media_cost_estimate` when the turn declares no volume). This is the central plumbing change — and a **breaking change to every `PreEgressHook`/`#makePreEgressHook` caller**, all updated in the 1.AF PR.
- The **existing** `MEDIA_BILLED_MODALITIES` (`['image','audio','video']`, `constants.ts`) — a **subset of `MEDIA_MODALITIES`** that excludes `document` (PDF bills as tokens), per [ADR-0031](0031-llm-seam-shape-amendment-multimodal-io.md) decision #4 / A6 — is the closed billed set, and is already what `Usage.mediaUnits.modality` keys on. (No new constant is introduced.)
- **Per-model media rate** on `ModelPricing` (and the `model_catalog` projection): **integer micro-cents** per the table convention — micro-cents **per image**, **per audio-second**, **per video-second** (a single `Math.round` per unit class, no float). **Unit caveat (reconciles 1.AF/D18):** a **token-based** provider reports audio as a raw token **count**, not seconds — OpenAI's `completion_tokens_details.audio_tokens` maps to `Usage.mediaUnits` as `unit: 'count'` with the raw count (NO fabricated tokens→seconds conversion, which would mis-bill). So the realized-cost fold (below) must switch on `MediaUnitsEntry.unit` and price the matching rate; an audio-`count` from such a provider is **observability-only** until a per-count (or a true-seconds) rate exists for that model — never hard-failing a run. A **`[defaults].media_cost_estimate`** config default (authored in [config-spec.md](../reference/contracts/config-spec.md)) is a per-modality **unit-count** default (the analogue of `max_tokens_estimate` — a count, not a price), used when a turn does not declare media-output volume.
- **Estimate:** projected total `= cumulative + token-estimate + media-estimate` (`units × rate`), pre-egress, reusing the **existing** `budget:warning` / `budget:paused` / `BudgetExceededError` / `BudgetPauseError` + `on_exceed` (`fail`/`pause_for_approval`/`warn`) surface — **no new event or error class**. The **unbounded short-circuit** (`max_cost_microcents <= 0 → allow`) stays **before** any estimate (no `/0` in `thresholdPct`); an **unpriced model / missing media rate degrades to allow** (the H4 mirror — never hard-fail a valid run on a missing rate).
- **Realized fold:** `CostTracker.record` folds `Usage.mediaUnits` (image per-count, audio/video per-second) into `cumulativeCostMicrocents` as a **separate addend** (never mixed into the token cost path), so the governor's running total — and the cumulative-cost-on-resume `Math.max` fold — includes realized media spend.
- **No new cap dimension (the contested decision):** the roadmap's "media budget caps" is realized as the **per-modality cost estimate folded into the existing `max_cost_microcents` cap**, **not** a distinct `max_media_count`/`max_media_bytes` field on `BudgetSchema`/`[chat]`. *Considered:* a distinct media count/bytes cap (ADR-0031:231-232's "size+count budget") — *deferred, not adopted*: it is a new authored-contract cap dimension every surface renders, and the cost cap already governs spend; a count/bytes cap can be added **additively** later if a concrete need arises (recorded in deferred-tasks.md). This keeps `BudgetSchema` stable and avoids a speculative contract.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Move normative hook/pricing contract details to reference docs and keep ADR at decision level.

Lines 45–50 currently encode concrete contract semantics (hook shape, billed modality/unit behavior, pricing fold mechanics). These should be canonicalized in docs/reference/contracts/* and linked from this ADR to prevent duplicated truth.

As per coding guidelines, docs/**/*.md requires “Specs live in [docs/reference/]; link, don't restate.” Based on learnings: “One canonical home per artifact.”

🤖 Prompt for 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.

In `@docs/decisions/0044-media-access-governance-read-media-save-to-cost.md`
around lines 45 - 50, The ADR currently contains detailed contract
specifications (PreEgressHook shape, MEDIA_BILLED_MODALITIES behavior, per-model
media rate pricing mechanics, estimate calculation fold logic, and realized cost
tracking) in lines 45-50 that should be canonicalized in reference documentation
rather than restated in the decision record. Extract these concrete semantic
details about hook shapes, modality/unit handling, and pricing fold mechanics
from the current content and move them to the appropriate file(s) in
docs/reference/contracts/ (likely alongside config-spec.md and other contract
references). Replace the detailed lines 45-50 in this ADR with a concise summary
of the decision rationale and links to the canonical reference documentation,
keeping only the high-level decision points and avoiding duplication of contract
truth.

Sources: Coding guidelines, Learnings

Comment on lines 185 to 188

> **`retry` on non-agent nodes.** `condition`, `transform`, and `merge` accept the same optional `retry`
> budget as `agent` — the engine's above-chain node-retry ([ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)): on a retryable failure the whole node is re-dispatched up to `max` total attempts with `backoff`/`backoff_ms`, optionally filtered by `retry_on`. The field shape is owned by [agent-yaml-spec.md](agent-yaml-spec.md#retry-vs-fallback). `input`, `output`, `parallel`, and `human_gate` carry no `retry` (they cannot produce a transient failure; a gate timeout is fatal).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix blockquote spacing to satisfy MD028.

Lines 185 and 188 insert blank lines inside a continuing blockquote, which triggers markdownlint MD028.

Suggested markdown fix
 > **Canvas vs. engine node taxonomy.** The desktop canvas renders a richer set of node *components* (e.g. `FanOutNode`, `AggregatorNode`, `LoopNode`, `ToolNode`), and the engine's internal node-type enum additionally recognizes `tool`, `loop`, and `subworkflow`. The v1.0 YAML above is the user-authored surface; the full catalog and how the two map is in [../shared-core/node-types.md](../shared-core/node-types.md).
-
 > **`retry` on non-agent nodes.** `condition`, `transform`, and `merge` accept the same optional `retry`
 > budget as `agent` — the engine's above-chain node-retry ([ADR-0040](../../decisions/0040-node-retry-budget-above-the-chain.md)): on a retryable failure the whole node is re-dispatched up to `max` total attempts with `backoff`/`backoff_ms`, optionally filtered by `retry_on`. The field shape is owned by [agent-yaml-spec.md](agent-yaml-spec.md#retry-vs-fallback). `input`, `output`, `parallel`, and `human_gate` carry no `retry` (they cannot produce a transient failure; a gate timeout is fatal).
-
 > **`save_to` on an `output` node (1.AF, [ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md) A9 / [ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md)).** An optional **relative** path template the surface writes generated media bytes to (e.g. `save_to: 'out/{{ run.id }}/image.png'`). It may interpolate `{{ run.id }}` (and any other template namespace). **Authoring fail-fast:** an absolute path (`/…`), a Windows drive (`C:\` / `C:/`), a UNC path (`\\…`), or a `..` traversal segment is rejected at parse. The DAG edge and every run event carry the media **handle**, never bytes — `save_to` materializes bytes only at the **surface** boundary, through a host write port that additionally enforces `realpath`+`commonpath` fail-closed against a scope root (security-review.md §Media byte delivery). Exported YAML therefore carries a handle or a relative `save_to` path, never bytes ([ADR-0009](../../decisions/0009-git-native-workflow-yaml.md)).
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 185-185: Blank line inside blockquote

(MD028, no-blanks-blockquote)


[warning] 188-188: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 Prompt for 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.

In `@docs/reference/contracts/workflow-yaml-spec.md` around lines 185 - 188, The
blockquote containing the retry documentation (starting with "> **`retry` on
non-agent nodes.**") has blank lines inserted within it between lines 185 and
188, which violates the MD028 markdown linting rule. Remove any blank lines that
appear inside this blockquote to maintain continuity, ensuring the entire retry
explanation remains as a single unbroken blockquote without internal line
breaks.

Source: Linters/SAST tools

Comment thread packages/core/src/engine/engine.ts
Comment thread packages/core/src/engine/engine.ts
Comment thread packages/db/drizzle/0002_round_umar.sql
Comment thread packages/db/src/client.test.ts
Comment thread packages/db/src/media-store.ts
…ettier (CI), complexity

CI blocker: prettier format:check failed on 8 files (incl. files from earlier
1.AF commits) — ran prettier --write; format:check now clean.

Still-valid review findings fixed:
- engine: resume()'s human_gate:resumed payload emit (the one resume event that
  can carry media) is wrapped so a de-inline throw fails the run + ALWAYS
  #schedule()s — a media gate payload can no longer strand a resumed run.
- db/media-store: #write is now atomic (temp file + rename, not a direct write),
  so an interrupted write never leaves a partial blob at the content-addressed
  path; #pathFor's root-boundary check no longer false-positives when the store
  root is the filesystem root (/ or C:\).
- db/client.test: replace the unsafe 'as run' cast with @ts-expect-error on the
  deliberately-invalid scope_kind constraint test.
- Sonar: extract messageMediaReason (capabilities), objectNodeIsDurableUnsafe
  (content), rewriteContainer (media-deinline) to drop cognitive complexity;
  Array.isArray guard for tool_result.media; flip the negated mediaStore spread
  in createInMemoryHost; String.raw for the save_to backslash test strings;
  stubMediaStore moved to module scope.
- docs: trim ADR-0042 §3 DDL restatement -> link database-schema.md (rule 8);
  ADR-0044 §3 forward-links its P4 contract homes; CLAUDE.md status reflects
  1.AF in progress (PR #33) with per-modality gating landed (not 'deferred').

Skipped (with reason): removing the ADR-0028/0029 in-place Amended notes
(contradicts documentation-style.md §7 + ADR-0009 + 4 precedents; amend != supersede,
and a prior review required them); a separate deInlineDraftSafely wrapper (#deInlineDraft
already IS the typed Promise<RunEventDraft> wrapper, Zod-guarded by #bus.next); migration
regen (db:generate reports no drift); MD028 blockquote nits (markdownlint not in CI;
repo uses adjacent blockquotes); charCodeAt->codePointAt (correct + simpler for ASCII
base64; codePointAt's number|undefined adds friction).

Validation: pnpm turbo lint/typecheck/test/build 16/16 green (shared 358, db 39,
llm 334, core 738) + format:check clean + Leakwatch clean.

Refs: ADR-0042, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/llm/src/capabilities.ts (1)

53-70: ⚡ Quick win

Split messageMediaReason to clear the configured cognitive-complexity threshold.

This function is just above the Sonar limit; extracting the tool_result media loop into a helper keeps behavior identical and avoids recurring CI friction.

♻️ Proposed refactor
 /** The first unsupported-modality reason in one message's content (its media + tool_result media), or null. */
 function messageMediaReason(
   inputCaps: CapabilityFlags['media']['input'],
   message: LlmRequest['messages'][number],
 ): string | null {
   for (const part of message.content) {
     if (part.type === 'media') {
       const reason = mediaInputReason(inputCaps, part.mimeType);
       if (reason !== null) return reason;
+      continue;
     }
-    // Array.isArray (not `!== undefined`): a null `media` from parsed JSON would otherwise throw on iterate.
-    if (part.type === 'tool_result' && Array.isArray(part.media)) {
-      for (const mediaPart of part.media) {
-        const reason = mediaInputReason(inputCaps, mediaPart.mimeType, ' in tool_result');
-        if (reason !== null) return reason;
-      }
+    if (part.type === 'tool_result') {
+      const reason = toolResultMediaReason(inputCaps, part);
+      if (reason !== null) return reason;
     }
   }
   return null;
 }
+
+type ToolResultPart = Extract<LlmRequest['messages'][number]['content'][number], { type: 'tool_result' }>;
+
+function toolResultMediaReason(
+  inputCaps: CapabilityFlags['media']['input'],
+  part: ToolResultPart,
+): string | null {
+  // Array.isArray (not `!== undefined`): a null `media` from parsed JSON would otherwise throw on iterate.
+  if (!Array.isArray(part.media)) return null;
+  for (const mediaPart of part.media) {
+    const reason = mediaInputReason(inputCaps, mediaPart.mimeType, ' in tool_result');
+    if (reason !== null) return reason;
+  }
+  return null;
+}
🤖 Prompt for 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.

In `@packages/llm/src/capabilities.ts` around lines 53 - 70, The
messageMediaReason function exceeds the configured cognitive-complexity
threshold. Extract the inner loop that handles tool_result media (the for loop
iterating through part.media) into a separate helper function. The new helper
should accept inputCaps and the media array as parameters, iterate through
mediaPart entries, call mediaInputReason for each, and return the first non-null
reason or null if none found. Update the messageMediaReason function to call
this new helper when processing tool_result parts, maintaining identical
behavior while reducing the overall complexity score.

Source: Linters/SAST tools

🤖 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.

Nitpick comments:
In `@packages/llm/src/capabilities.ts`:
- Around line 53-70: The messageMediaReason function exceeds the configured
cognitive-complexity threshold. Extract the inner loop that handles tool_result
media (the for loop iterating through part.media) into a separate helper
function. The new helper should accept inputCaps and the media array as
parameters, iterate through mediaPart entries, call mediaInputReason for each,
and return the first non-null reason or null if none found. Update the
messageMediaReason function to call this new helper when processing tool_result
parts, maintaining identical behavior while reducing the overall complexity
score.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 15b55011-c037-4e48-a24f-b49699702c30

📥 Commits

Reviewing files that changed from the base of the PR and between e639717 and 42f4798.

📒 Files selected for processing (16)
  • CLAUDE.md
  • docs/decisions/0042-engine-media-storage-substrate-mediastore-deinline-retention.md
  • docs/decisions/0044-media-access-governance-read-media-save-to-cost.md
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/engine/engine.ts
  • packages/core/src/engine/execution-host.ts
  • packages/db/src/client.test.ts
  • packages/db/src/media-store.ts
  • packages/llm/src/adapters/openai.ts
  • packages/llm/src/capabilities.test.ts
  • packages/llm/src/capabilities.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts
  • packages/shared/src/media-deinline.test.ts
  • packages/shared/src/media-deinline.ts
  • packages/shared/src/node.test.ts
✅ Files skipped from review due to trivial changes (2)
  • docs/decisions/0044-media-access-governance-read-media-save-to-cost.md
  • CLAUDE.md
🚧 Files skipped from review as they are similar to previous changes (12)
  • packages/core/src/engine/execution-host.ts
  • packages/db/src/media-store.ts
  • packages/llm/src/capabilities.test.ts
  • packages/llm/src/adapters/openai.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/node.test.ts
  • packages/core/src/engine/engine.ts
  • packages/db/src/client.test.ts
  • packages/shared/src/media-deinline.test.ts
  • packages/core/src/engine/engine.test.ts
  • packages/shared/src/content.ts
  • packages/shared/src/media-deinline.ts

cemililik and others added 3 commits June 18, 2026 22:28
…(PR #33 nit)

Split the tool_result media inner loop out of messageMediaReason into a small
toolResultMediaReason helper — identical behavior, lower per-function cognitive
complexity. capabilities tests unchanged + green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…33 Sonar)

Sonar S3776 brain-overload (both Critical) + the es2015 codePointAt smell:
- core/engine: extract #resolveBudgetGate from resume() (was 19 > 15) — the two
  budget-gate arms (reject ⇒ run-level budget failure; approve ⇒ continue the
  deferred pre-egress call) move into one private method; resume() keeps the
  schedule()/return and the general gate-completed path. Behavior identical.
- llm/capabilities: extract partMediaReason from messageMediaReason (was 16 > 15)
  so the loop body is a single call; per-part media/tool_result branching lives
  in the helper.
- shared/content: charCodeAt -> codePointAt (?? 0) in the base64 decode table
  build + the decode loop + the content.test byte helper. The ?? 0 is unreachable
  (every position is validated to exist before the lookup; base64 is pure ASCII),
  so behavior is unchanged — this just clears the i18n-safety smell.

Skipped: node.ts L58 String.raw — the string is a prose error message with one
literal backslash ("\\"); String.raw would force \" which reads as an escaped
quote, not a literal backslash (strictly less readable). False positive for prose.

Validation: pnpm turbo lint/typecheck/test/build 16/16 (shared 358, db 39,
llm 334, core 738) + format:check clean + Leakwatch 0 findings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nal-review follow-ups

SECURITY (I3) — the one merge-blocking finding of the final multi-dimensional
review (9 reviewers → 2-lens adversarial verify → synthesis; 36 raw → 19 confirmed):

- shared/media-deinline: a url media part with NO mimeType slipped past the
  rewrite walk. The SCAN side (content.ts isUrlMediaPart) flags
  { type:'media', source:{kind:'url'} } with no mimeType requirement (so it forces
  the walk), but the REWRITE side (isInflightMediaPart) REQUIRES a string mimeType
  — so a mimeType-less url media part missed the throwing branch, fell through to
  the generic record clone, and the un-re-hosted url was silently preserved into a
  durable run-event (verified: node:completed.output is z.unknown() and
  RunEventSchema.superRefine has no media scan, so no downstream backstop catches
  it). Added a dedicated url-source guard in rewrite() that throws regardless of
  mimeType, mirroring isUrlMediaPart exactly — closing the scan/rewrite asymmetry.
  Reachable only via an opaque/malformed media-like object, but that is precisely
  the totality class deInlineMedia exists to fail-closed on.

Regression tests pinning the fix + previously-uncovered fail-closed paths:
- shared/media-deinline.test: url-no-mimeType throw (bare + nested); invalid-base64
  and unknown-source-kind (co-located so the scan runs); single-put for a shared ref.
- core/engine.test: gate-resume with a media-bearing decision.payload through the
  I3 choke point — with-store (handle in human_gate:resumed, no base64 delivered or
  persisted) and no-store (one terminal run:failed code 'internal', no hang, no leak).
- db/media-store.test: sha256 integrity-on-read rejects a tampered CAS blob.

Non-blocking follow-ups from the same review:
- core/engine: corrected two now-stale comments — #onOutcome's catch (a NON-terminal
  media de-inline re-throw reaches it BY DESIGN, mapped to run:failed) and
  #emitDurable's 'total' docstring (scoped to store faults; the de-inline re-throw is
  the deliberate exception).
- docs/roadmap (phase-1): the 'Remaining (P3+P4)' doc list wrongly included
  database-schema / workflow-yaml-spec / sse-event-schema, which already landed in
  P1+P2; only config-spec (P4) + security-review (P3/D9) remain. current.md date bump.
- ADR-0042 §4: the 7-day GC grace window no longer implies an existing [defaults] key
  — forward-declared as a P4/D11 addition.

Skipped: commit-scope nit on the (merged, append-only) 42f4798 — no commitlint in CI,
this commit uses a proper package scope instead; database-schema.md 'Last updated'
header (not consistently maintained; prior content-adds never bumped it); the optional
RunEventSchema containsDurableUnsafeMedia backstop (redundant — deInlineMedia at the
choke point runs before validation/persist, so the primary guard already closes it).

Validation: pnpm turbo lint/typecheck/test/build 16/16 (shared 362, db 40, llm 334,
core 740; +7 new tests) + format:check clean + Leakwatch 0 findings.

Refs: ADR-0042, ADR-0043

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cemililik

Copy link
Copy Markdown
Contributor Author

@coderabbitai review all

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

@cemililik Sure! I'll kick off a full review of all the changes in this PR.

✅ Action performed

Full review finished.

@cemililik
cemililik merged commit 0538c8d into main Jun 18, 2026
9 checks passed
@sonarqubecloud

Copy link
Copy Markdown

cemililik added a commit that referenced this pull request Jun 19, 2026
…erge)

Status snapshot for the follow-on 1.AF PR (P1+P2 already merged via PR #33):
- phase-1-engine-and-llm.md: split the 1.AF status into Merged (P1+P2, PR #33), Landed on
  development (P3 D7/D8/D9 + the P3 egress/SSRF security-review + P4/D13 the byte-delivery
  Range gate, pending merge), and Remaining (P4: D12 read_media + authz, D16 save_to, D15
  load-check, D17 cost governor, D11 terminal sweep + GC, the keychain IPC test, the P4
  byte-delivery security-review, + the still-pending built-in-tools/config-spec/security-review
  doc homes).
- current.md + CLAUDE.md (x2): same Merged / landed-pending-merge / remaining split.

Per Roadmap-Done-After-Merge, nothing is marked Done — P3/D13 are 'landed on development,
pending merge'; the 1.AF matrix row stays ◇ until the PR(s) merge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cemililik added a commit that referenced this pull request Jun 20, 2026
…surfaces

All 1.AF (engine media plumbing) PRs are merged to main (2026-06-20): P1+P2 (#33),
P3 + P4/D13 (#34), P4 remainder (#35), and the multi-agent + external review
follow-ups (#36). Flip the status markers from in-progress / pending-merge to Done
across CLAUDE.md, README.md, current.md, the phase-1 task entry + matrix row + the
1.m6 milestone row, and the deferred-tasks note.

The deferred host-wiring half (D12 MediaReadAccess + session-scope population, the
D15 loader call, the D17/resolveForEgress config) and the keychain no-raw-key IPC
test remain owned by 1.AH (already recorded in deferred-tasks.md). Remaining
Phase-1 work: 1.AG/1.AH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant