Skip to content

1.AF P4 — media access & spend governance (read_media, save_to, cost governor, retention/GC)#35

Merged
cemililik merged 15 commits into
mainfrom
development
Jun 19, 2026
Merged

1.AF P4 — media access & spend governance (read_media, save_to, cost governor, retention/GC)#35
cemililik merged 15 commits into
mainfrom
development

Conversation

@cemililik

@cemililik cemililik commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

1.AF P4 — media access & spend governance (the P4 remainder)

Completes workstream 1.AF (engine media plumbing) P4 — the media access & spend boundaries on the ADR-0031 seam. Builds on PR #33 (P1+P2) and PR #34 (P3 + D13), both merged. Lands the engine-pure policy; the host mechanism/wiring half is 1.AH/surface (recorded in deferred-tasks.md §1.AF P4).

Design ADRs: 0042 (storage/retention) · 0043 (egress) · 0044 (access/spend).

What landed

  • D12 — read_media — the 13th built-in tool + one engine-pure scope-set authz primitive (scopeSetIncludes; owner-equality rejected, sha256-knowledge ≠ authorization) + the byte-delivery Range gate (validateByteRange, fail-closed) + the additive ToolPolicyDenyReason.media_scope_denied; the media_objects/media_references junction store (MediaReferenceStore + createMediaReferencePort), recording a produced handle's run reference at the #emitDurable choke point.
  • D11 — the terminal-state sweep (reclaim a run's run refs at run:completed|failed|cancelled) + reclaimExpired grace-window GC (soft-delete zero-ref objects past the configured grace).
  • D15validateWorkflowWithCatalog: the engine-loader output_modalitiesmedia.outputCombinations membership check (defers an unresolvable model; the runtime FallbackChain pre-skip — now wired onto the request — is the backstop).
  • D16 — save_toMediaWritePort (shared) + ExecutionHost.mediaWrite + the engine output-node orchestration (resolve {{ run.id }} → relative path, extract the single produced handle → MediaStore.get → write; fails the node, not best-effort) + the db createFilesystemMediaWrite fail-closed jail (realpath+commonpath, symlinks off, atomic wx temp + rename, reason-only MediaWriteError) + the new run.id interpolation namespace.
  • D17 — the per-modality media cost governor: ModelPricing.mediaOutputRates + mediaCost/estimateMediaCost (a disjoint addend folded into the existing max_cost_microcents cap, degrade-to-0 on a missing rate) + the widened PreEgressHook (outputModalities/mediaUnitsEstimate) + [defaults].media_cost_estimate + the model_catalog media-rate columns (migration 0003).

Reviews (both adversarial, multi-agent)

  • A dedicated P4 byte-delivery security review (two independent passes) — 0 blockers/highs, no traversal/symlink-escape/arbitrary-write reachable; 2 mediums + cheap lows fixed (dd03cc8).
  • A comprehensive 46-agent review of the whole P4 changeset (9 dimensions, every finding double-verified) — 0 blockers/highs in reachable code. Its real findings were fixed in e6e8545:
    • [high] output_modalities is now lowered onto the LlmRequest, so the FallbackChain output-combination pre-skip (the D15 runtime backstop) actually fires — previously an incapable model silently returned text.
    • [med] reconcile() best-effort reclaims a crashed run's media refs (no permanent orphan).
    • [med] boundForModel is media-aware — a read_media/media tool result never serializes base64 into agent:tool_result.outputSummary/spill (the I3 gap the emit choke point can't catch).
    • [low] read_media 0-byteLength whole-handle off-by-one; wx temp hardening; run.id / best-effort regression pins.

Deferred to 1.AH (recorded, not in this PR)

The host-wiring half: the D12 host MediaReadAccess impl + session-scope population, the D15 loader call, the D17 config/resolveForEgress wiring — so D12/D15/D17 are engine-policy-complete but inert end-to-end until a surface wires them. The keychain no-raw-key IPC test (no Phase-1 desktop surface; ADR-0044 §4).

Conformance

  • pnpm turbo run lint typecheck test build16/16 (786 core tests)
  • pnpm run format:check clean · Leakwatch 0
  • Engine purity (zero platform imports in packages/core) — save_to fs mechanism lives in @relavium/db
  • @relavium/llm seam holds (no vendor type crosses); secrets/I3 (no bytes/path/secret in events/logs)
  • Canonical-home docs updated (workflow-yaml-spec, security-review, config-spec, database-schema)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added read_media tool for controlled, scope-authorized byte-range access.
    • Added save_to for output nodes to persist media bytes using {{ run.id }} (with safer path handling).
    • Added workflow output_modalities validation against model capability sets.
    • Added project-level [defaults.media_cost_estimate] defaults for media-output unit estimates.
  • Bug Fixes / Improvements
    • Extended budget governance and cost estimation to incorporate media-unit estimates.
    • Improved handling and security redaction for inline media payloads in model-facing outputs.
  • Documentation
    • Updated config, workflow YAML, and security checklist references for media cost estimation and {{ run.id }} semantics.

cemililik and others added 13 commits June 19, 2026 15:36
…rship primitive

The engine-pure half of the read_media byte-delivery authz (ADR-0044 §1), landed before its
consumers (the D12 read_media tool + the 1.AH desktop command) like the rest of the seam shapes:
- ScopeSchema / Scope — a { kind: 'session' | 'workspace', id } authz scope (workspace reserved,
  documented-not-implemented), keyed on the existing MEDIA_AUTHZ_SCOPE_KINDS.
- scopeSetIncludes(allowed, requesting) — grants read ONLY on exact kind+id membership in a handle's
  allowedScopes (the host-read media_references session/workspace rows); owner-equality is not
  expressible and an empty set fails closed. Knowing a sha256 is not authorization.

Reused by both surfaces so the authz is written once. +2 tests (schema accepts session/workspace,
rejects run/node + empty id; membership grants only on exact kind+id, fail-closed on empty).

The read_media tool itself + the ToolDispatchContext media-read delegate + media_scope_denied +
the media_references row-writing lifecycle land in the following D12 increments.

Refs: ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e-set authz + Range gate)

The 13th built-in (ADR-0044 §1). Engine-pure byte-delivery gate; the MediaStore + media_references
access is an injected ctx.mediaRead delegate (NOT a ToolHost capability arm — mirrors invokeAgent),
so the dispatch never touches raw bytes:
- tools/types: MediaReadAccess (describe(handle) -> { mimeType, byteLength, allowedScopes } + readRange
  -> in-flight base64 MediaSource) + ToolDispatchContext.requestingScope + .mediaRead delegate.
- tools/errors: additive ToolPolicyDenyReason.media_scope_denied (no exhaustive switch breaks — reasons
  are construction-site only).
- tools/builtins: read_media { handle, start?, end? } — dispatch does authz FIRST (scopeSetIncludes on
  the handle's allowedScopes; deny -> media_scope_denied; never owner-equality), then validateByteRange
  (whole-handle default; fail-closed BEFORE any host read), then the host readRange; returns an in-flight
  media part. MEDIA_POLICY (read-only, no fs-scope/egress/gate; bypasses the Phase-2 ActionGuard).

+6 tests (authz grant whole-handle + explicit range; media_scope_denied; unknown handle; out-of-bounds
fails before the read; unavailable without the delegate). Built-in count 12 -> 13.

The host wiring (engine builds ctx.mediaRead from ExecutionHost.mediaStore + a media_references db port)
and the media_references row-writing lifecycle are the next D12 increment (D12c, paired with D11 GC).

Refs: ADR-0044, ADR-0029

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

The Node/SQLite host reference for the media retention + authz junction (ADR-0042 §3-4 / ADR-0044 §1).
One junction serves both the refcount/terminal-sweep (run/node rows) and read_media authz (session/
workspace rows). Lives in @relavium/db (better-sqlite3); the pure engine never imports it — a host
wires it behind the engine reference-lifecycle port + the read_media MediaReadAccess delegate.

- recordObject(input): upsert media_objects, idempotent on the content-addressed handle (a conflict
  just refreshes last_referenced_at, the GC cursor — bytes are identical).
- addReference(handle, scopeKind, scopeId): idempotent on the (handle, scopeKind, scopeId) unique index
  (the refcount is the distinct-row count). The handle FK targets media_objects.handle, so a stray
  reference fails closed at the FK.
- describe(handle): a LIVE handle's mimeType/byteLength + its session/workspace authz scopes ONLY
  (run/node lifetime rows excluded — they never grant read); undefined for an unknown or deleted_at
  (GC-reclaimed) handle, so read_media fails closed. Explicit kind-narrowing → Scope[] (no unsafe cast).
- removeRunReferences(runId): D11 terminal sweep — removes a run's run-kind rows; returns the count.

+6 tests (authz scopes filtered from lifetime rows; unknown/deleted → undefined; addReference +
recordObject idempotency; sweep removes only the run rows, leaves session authz intact).

The engine wiring (the reference-lifecycle port: record at the deInline choke point + the terminal
reclaim; the read_media ctx.mediaRead delegate assembly) is the next increment (D12c-engine + D11).

Refs: ADR-0042, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ediaHandles (engine contracts)

The shared half of the media-reference lifecycle wiring (ADR-0042 §3-4):
- MediaReferencePort: the host lifecycle port the pure engine calls — recordRunMedia(meta, runId) at the
  de-inline choke point + reclaimRun(runId) at the terminal sweep (D11). Documented best-effort + optional:
  a record/reclaim failure is a retention concern (GC over/under-retention), NEVER an I3 or run-correctness
  break, so it must never fail the run. (Session/workspace read-grants are the surface/AgentSession concern.)
- DurableMediaMeta: the produced-handle metadata mirror (handle/mimeType/modality/byteLength/durationMs?).
- collectDurableMediaHandles(value): cycle-safe, handle-deduped collection of the durable handle media
  parts in an already-de-inlined value (mirrors the containsDurableUnsafeMedia stack walk). Skips a
  non-handle source, a handle part missing byteLength, or an unknown modality.

+4 tests (metadata incl. durationMs; dedupe across array/Map/Set/cycle; skip base64/url/no-byteLength/
unknown-modality; empty). The db adapter (createMediaReferencePort) + the engine wiring (record at
#emitDurable, reclaim at terminal) are the next two increments.

Refs: ADR-0042, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…Store -> engine port adapter)

Adapts the MediaReferenceStore to the engine's MediaReferencePort (shared): recordRunMedia(meta, runId)
= recordObject(meta) + addReference(handle,'run',runId); reclaimRun(runId) = removeRunReferences(runId).
DurableMediaMeta is structurally MediaObjectInput, so the record forwards directly. A host wires this
behind ExecutionHost.mediaReferences; the pure engine calls only the shared port. +1 test.

Refs: ADR-0042, ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…at choke point, reclaim at terminal)

Wires the media-reference lifecycle into the engine (ADR-0042 §3-4), completing D12c + the D11 terminal
sweep:
- ExecutionHost.mediaReferences? (the shared MediaReferencePort) + createInMemoryHost option.
- #emitDurable: after the event is stamped, #recordProducedMedia(durable) records the run's reference for
  every produced durable handle (collectDurableMediaHandles), and at a TERMINAL event #reclaimRunMedia()
  runs the D11 sweep (release the run's run-kind refs; a session/workspace read-grant survives).
- Both go through #bestEffortMediaRef: a sync throw or async rejection is swallowed (.catch), so a
  retention-port failure NEVER touches the I3 / gap-free / exactly-one-terminal guarantees — the run is
  unaffected. No-op without the port or when an event carries no handle.

+2 engine tests (a produced handle is recorded for the run + reclaimed once at the terminal; a THROWING
port leaves the run completing normally with no byte leak).

The grace-window byte-GC (the periodic 0-ref reclaim past last_referenced_at) is a host-periodic db
method (D11-gc), landing next; the read_media ctx.mediaRead/requestingScope wiring is the surface/
AgentSession concern (session scope), recorded for 1.AH.

Refs: ADR-0042, ADR-0044

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

The grace-window byte-GC (ADR-0042 §4 step c): soft-deletes (sets deleted_at) every LIVE object that
now has ZERO references AND whose last_referenced_at is older than graceMs before now; returns the
reclaimed handles so the host can delete their CAS bytes. A host periodic job calls it with the
configured grace (default 7 days); the terminal sweep (removeRunReferences / the engine reclaim) is
what drops a handle to zero refs first. Single-connection select-then-update is consistent; the update
targets exactly the expired handles (not a re-run of the 0-ref filter, which would ignore the grace).
Idempotent (an already-deleted object is skipped). +1 test (still-referenced never reclaimed; not-yet-
expired skipped; zero-ref + past-grace reclaimed; idempotent).

Refs: ADR-0042

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…es load-check

The engine-loader capability pass (ADR-0044 §2): validates every agent node's authored output_modalities
against its resolved model's media.outputCombinations MEMBERSHIP, using a host-provided model->capabilities
catalog (WorkflowModelCatalog, sourced from the DB model_catalog). Runs as a separate pass because
WorkflowSchema.superRefine has no catalog (the core->llm parse-time dependency, not circular). Behavior:
- a requested combination must EXACTLY match one declared outputCombinations entry (a per-model combination
  constraint, not a subset of their union);
- a model absent from the catalog is DEFERRED (no error — unresolvable at load; the runtime FallbackChain
  per-modality pre-skip is the backstop, never a silent drop);
- an incapable model throws a field-named WorkflowValidationError listing each offending node (secret-free:
  a model id + the modality set).

+5 tests (member passes; non-member throws field-named; unresolvable defers; no-modalities/no-model skipped;
exact-match required).

Refs: ADR-0044, ADR-0031

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…run.id namespace

Land save_to (ADR-0044 §2): the engine resolves an output node's save_to path
template and its single produced media handle to bytes, then writes via a
host-injected MediaWritePort — policy in the platform-pure engine, the
filesystem mechanism in @relavium/db.

- shared: MediaWritePort + MediaWriteResult — the host save_to write contract
  (relativePath, bytes, signal) -> { bytesWritten }, not best-effort (a write
  failure fails the node, unlike the retention MediaReferencePort).
- core/interpolation: a new run.id namespace (kind 'run'); RunScope.runId,
  resolved only where a runId is in scope (today save_to) — a {{ run.id }}
  elsewhere is a typed unresolved_reference, never an empty string. The lexer
  lookahead stops run.identity/run.id_x from collapsing to run.id; run.outputs
  stays the node namespace.
- core/engine: ExecutionHost.mediaWrite?; #applySaveTo/#performSaveTo run after
  the output executor completes — de-inline the captured output to its handle
  (deInlineMedia is non-mutating, so state.output keeps the raw in-flight form),
  require exactly one handle, MediaStore.get -> MediaWritePort. save_to resolves
  ONLY {{ run.id }} (no inputs/ctx/run.outputs into a filesystem path). Secret-
  free typed failures (validation / internal); never echoes the path/bytes/host
  reason (I3).
- db: createFilesystemMediaWrite(scopeRoot) — fail-closed jail (reject absolute/
  drive/UNC/.., realpath the root, verify the deepest existing ancestor is in-
  root BEFORE mkdir, refuse a final-component symlink, atomic temp+rename).
- docs: workflow-yaml-spec (save_to resolves run.id only) + security-review
  (§Media byte delivery — the save_to write jail), both canonical homes.

Toolchain: lint/typecheck/test/build 16/16 green; prettier clean; Leakwatch 0.

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

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

Add a disjoint media cost class to the ADR-0028 pre-egress governor + the
realized cost fold (ADR-0044 §3). Media bills per image / audio-second /
video-second, never in tokens, folded into the SAME max_cost_microcents cap —
no new cap dimension, no new event/error class.

- llm/pricing: ModelPricing.mediaOutputRates? (µ¢ per image/audio-sec/video-sec);
  every shipped row leaves it undefined (no model prices media output yet).
- llm/cost-tracker: mediaCost(pricing, mediaUnits) — a disjoint addend folded into
  cost(); prices only output-direction entries whose unit matches the modality's
  billed unit (image=count, audio/video=second); a missing rate or a token-`count`
  audio unit is observability-only (degrade-to-0, H4). Realized fold flows into
  cumulativeCostMicrocents.
- llm/budget-estimator: estimateMediaCost(model, estimate) — Σ units×rate; unpriced
  modality degrades to 0; UnknownModelError for an unlisted model (governor catches).
- core/agent-turn: widen PreEgressHook + AgentTurnParams with outputModalities? +
  mediaUnitsEstimate?; awaitPreEgress forwards them (the chain's narrower PreAttemptHook
  is unchanged — media is gated at the loop-top vs the primary model).
- core/budget-governor: evaluate/checkPreEgress fold estimateMediaCost into the
  projection; the unbounded short-circuit + the UnknownModelError degrade-to-allow
  stay before/around it.
- core/agent-runner: buildMediaUnitsEstimate(output_modalities, [defaults].media_cost_estimate)
  — one entry per BILLED modality (text/document excluded), config count else the
  built-in DEFAULT_MEDIA_UNIT_ESTIMATE; AgentRunnerDeps.mediaCostEstimate.
- shared/config: [defaults].media_cost_estimate (per-modality unit COUNT default).
- db: model_catalog gains nullable media_{image,audio,video}_cost_microcents columns
  (the ModelPricing.mediaOutputRates projection) + migration 0003.
- docs: config-spec ([defaults.media_cost_estimate]) + database-schema (model_catalog
  media-rate columns), the canonical homes.

Toolchain: lint/typecheck/test/build 16/16 green; prettier clean; Leakwatch 0.

Refs: ADR-0044, ADR-0028, ADR-0031

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply the two MEDIUM findings + cheap LOWs from the P4 byte-delivery security
review (two independent adversarial passes — no blockers/highs; no traversal /
symlink-escape / arbitrary-write reachable):

- core/engine #performSaveTo: classify an InterpolationError (a bad save_to path
  TEMPLATE — e.g. a non-run.id reference resolving to nothing) as `validation`,
  not `internal`; a genuine write failure stays `internal`. Secret-free message.
- db/media-write: a reason-only MediaWriteError + a boundary that re-wraps any RAW
  Node fs error (realpath/mkdir/writeFile/rename/lstat — whose message/path/dest
  carry the absolute path) so the resolved path can never escape the port (the I3
  self-defending contract, regardless of caller). Atomic-publish temp now reclaimed
  in a `finally` on ANY non-success exit (writeFile/rename throw or cancel) — no
  orphaned `.tmp`. Documented the (write-free) stray-empty-dir residual on a
  symlinked-ancestor mkdir.
- core/agent-runner buildMediaUnitsEstimate: replace the `as MediaBilledModality`
  cast with an `isBilledModality` type guard (narrow without a cast).
- Tests: save_to bad-template → validation; scope-root-missing → reason-only
  MediaWriteError with no path leak.

Toolchain: lint/typecheck/test/build 16/16 green; prettier clean; Leakwatch 0.

Refs: ADR-0044

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bring the roadmap status current: PR #34 (P3 + P4/D13) merged; the P4 remainder
(D12, D11, D15, D16, D17 + the byte-delivery review + canonical-home docs) is
landed on `development`, pending merge. The keychain no-raw-key IPC test is
recorded as deferred to 1.AH (no Phase-1 desktop surface; ADR-0044 §4). The
matrix row stays ◇ until the PR merges (Roadmap-Done-After-Merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apply the real findings from the 46-agent review of the P4 changeset (9
dimensions, every finding double-verified; 0 blockers/highs in reachable code).

A — real defects fixed:
- A1 [high] output_modalities is now lowered onto the LlmRequest (buildRequest),
  so the FallbackChain output-combination pre-skip — the runtime backstop the
  D15 load-check defers to (ADR-0044 §2) — actually fires; previously an
  incapable model silently returned text. + a skip-backstop test (incapable
  model → chain exhausts → typed failure, never a text drop).
- A2 [med] reconcile() best-effort reclaims a crashed non-resumable run's media
  refs (#bestEffortReclaim), so a crash no longer permanently orphans partial
  media (ADR-0042 §4); retention failure never abandons reconciliation. + tests.
- A3 [med] boundForModel is media-aware: a read_media/media tool result's inline
  base64 is redacted from the text path (toText), so it can never serialize into
  agent:tool_result.outputSummary / the spill / the over-cap preview — the I3 gap
  the emit-time deInlineMedia choke point cannot catch (a base64 substring inside
  a flat string). The model-facing value keeps the bytes (rides the seam). + tests.
- A4 [low] read_media whole-handle read of a 0-byteLength handle returns an empty
  source instead of tripping the default end = byteLength-1 = -1 range rejection. + tests.

B — cheap hardening:
- wx (O_CREAT|O_EXCL) on the save_to temp write — refuses a pre-existing symlink
  at the temp path (defense-in-depth atop the unguessable UUID name).
- async-rejection arm of the run-loop best-effort media-ref port now tested
  (no unhandled rejection); run.id no-spurious-edge / not-a-secret-source /
  not-a-pre-run-violation regressions pinned in dag/analyze tests.
- documented the url-bearing save_to double-fetch tradeoff.

D — recorded the deferred HOST-WIRING half (D12 host MediaReadAccess + session-
scope population, the D15 loader call, the D17 config/resolveForEgress wiring) in
deferred-tasks.md §1.AF P4 + the roadmap, so D12/D15/D17 are not read as live
end-to-end — the engine policy is complete + tested; the surface wiring is 1.AH.

Toolchain: lint/typecheck/test/build 16/16 green (786 core tests); prettier
clean; Leakwatch 0.

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

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 19, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 29c9543f-74da-4cd3-ab8a-56745cb00df0

📥 Commits

Reviewing files that changed from the base of the PR and between e6e8545 and a6a4e55.

📒 Files selected for processing (18)
  • docs/roadmap/deferred-tasks.md
  • packages/core/src/engine/agent-runner.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/interpolation/analyze.test.ts
  • packages/core/src/interpolation/references.ts
  • packages/core/src/interpolation/resolve.test.ts
  • packages/core/src/tools/bounding.test.ts
  • packages/core/src/tools/bounding.ts
  • packages/core/src/tools/builtins.test.ts
  • packages/core/src/tools/builtins.ts
  • packages/db/src/media-reference-store.test.ts
  • packages/db/src/media-reference-store.ts
  • packages/db/src/media-write.test.ts
  • packages/llm/src/pricing.ts
  • packages/shared/src/config.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/roadmap/deferred-tasks.md
🚧 Files skipped from review as they are similar to previous changes (13)
  • packages/llm/src/pricing.ts
  • packages/db/src/media-reference-store.test.ts
  • packages/shared/src/config.ts
  • packages/shared/src/content.test.ts
  • packages/core/src/engine/agent-runner.ts
  • packages/core/src/tools/bounding.ts
  • packages/core/src/interpolation/references.ts
  • packages/db/src/media-reference-store.ts
  • packages/core/src/interpolation/analyze.test.ts
  • packages/shared/src/content.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/db/src/media-write.test.ts
  • packages/core/src/engine/engine.test.ts

📝 Walkthrough

Walkthrough

Phase 1 / P4 multimodal media delivery: adds {{ run.id }} as a first-class interpolation namespace, a read_media built-in tool with scope-set authz and inclusive byte-range gate, per-modality media cost governance wired through BudgetGovernor, a save_to write port backed by a symlink-averse jailed filesystem writer, durable media reference recording and terminal reclamation in the engine, catalog-aware output-modality validation, DB schema columns and migration for media cost rates, and corresponding docs/roadmap updates.

Changes

1.AF P4 Multimodal Media Delivery

Layer / File(s) Summary
Shared media auth contracts, host ports, and config schema
packages/shared/src/content.ts, packages/shared/src/content.test.ts, packages/shared/src/config.ts, packages/shared/src/config.test.ts
Defines ScopeSchema/Scope, scopeSetIncludes, DurableMediaMeta, MediaReferencePort, MediaWritePort, collectDurableMediaHandles, and the MediaCostEstimate config schema with strict Zod validation; all covered with unit tests.
{{ run.id }} interpolation namespace
packages/core/src/interpolation/references.ts, packages/core/src/interpolation/scope.ts, packages/core/src/interpolation/resolve.ts, packages/core/src/interpolation/references.test.ts, packages/core/src/interpolation/resolve.test.ts, packages/core/src/interpolation/analyze.test.ts, packages/core/src/dag.test.ts, docs/reference/contracts/workflow-yaml-spec.md
Adds run to ReferenceKind, introduces a RUN_ID regex with negative lookahead, adds RunScope.runId, resolves run.id to scope.runId (or unresolved_reference), and confirms the reference creates no DAG dependency edge.
Media pricing: ModelPricing rates, cost tracker, budget estimator, DB columns
packages/llm/src/pricing.ts, packages/llm/src/cost-tracker.ts, packages/llm/src/budget-estimator.ts, packages/llm/src/index.ts, packages/llm/src/cost-tracker.test.ts, packages/llm/src/budget-estimator.test.ts, packages/db/src/schema.ts, packages/db/drizzle/0003_cloudy_wind_dancer.sql, packages/db/drizzle/meta/*
Adds mediaOutputRates to ModelPricing, implements mediaCost as a disjoint addend in cost(), adds estimateMediaCost/MediaUnitsEstimate for pre-egress projection, and extends model_catalog with three nullable microcent columns plus migration and Drizzle snapshot.
read_media built-in tool: types, authz, range gate, base64 redaction
packages/core/src/tools/types.ts, packages/core/src/tools/errors.ts, packages/core/src/tools/builtins.ts, packages/core/src/tools/registry.test.ts, packages/core/src/tools/bounding.ts, packages/core/src/tools/bounding.test.ts, packages/core/src/tools/builtins.test.ts
Introduces MediaHandleInfo, MediaReadAccess, ToolDispatchContext.mediaRead/requestingScope, media_scope_denied deny reason, and readMediaTool with handle validation, scope authz, zero-byte short-circuit, and range gating; also redacts base64 from model-facing summaries.
Agent runner & turn: output_modalities forwarding and media cost governance
packages/core/src/engine/agent-runner.ts, packages/core/src/engine/agent-turn.ts, packages/core/src/engine/budget-governor.ts, packages/core/src/engine/agent-runner.test.ts, packages/core/src/engine/budget-governor.test.ts
Extends AgentRunnerDeps with mediaCostEstimate, adds DEFAULT_MEDIA_UNIT_ESTIMATE/buildMediaUnitsEstimate, threads outputModalities and mediaUnitsEstimate through AgentTurnParams and PreEgressHook into BudgetGovernor.evaluatePreEgress/checkPreEgress.
validateWorkflowWithCatalog catalog-aware output-modality validation
packages/core/src/validate-catalog.ts, packages/core/src/validate-catalog.test.ts, packages/core/src/index.ts
Adds WorkflowModelCatalog type and validateWorkflowWithCatalog that checks each agent node's output_modalities against the model's declared output combinations, throws WorkflowValidationError on mismatch, and is exported from the core public surface.
DB media-reference store and jailed filesystem media-write
packages/db/src/media-reference-store.ts, packages/db/src/media-reference-store.test.ts, packages/db/src/media-write.ts, packages/db/src/media-write.test.ts, packages/db/src/index.ts
Implements createMediaReferenceStore (idempotent object upsert, run-scoped reference tracking, scope-based describe, grace-window GC), createMediaReferencePort adapter, and createFilesystemMediaWrite with fail-closed realpath jail, symlink refusal, and atomic wx+rename publish.
ExecutionHost: mediaReferences and mediaWrite port wiring
packages/core/src/engine/execution-host.ts
Extends ExecutionHost interface and createInMemoryHost with optional mediaReferences and mediaWrite ports via conditional spreads.
Engine: save_to delivery, media reference lifecycle, and reconcile reclaim
packages/core/src/engine/engine.ts, packages/core/src/engine/engine.test.ts
Adds #applySaveTo/#performSaveTo to resolve save_to path templates, enforce exactly-one-handle semantics, and write via host mediaWrite; adds #recordProducedMedia/#reclaimRunMedia called from #emitDurable for terminal sweeps; adds #bestEffortReclaim in reconcile for crashed runs; 316-line test expansion covers all paths.
Docs and roadmap updates
docs/reference/contracts/config-spec.md, docs/reference/desktop/database-schema.md, docs/standards/security-review.md, docs/roadmap/current.md, docs/roadmap/deferred-tasks.md, docs/roadmap/phases/phase-1-engine-and-llm.md
Updates config-spec with [defaults.media_cost_estimate], database-schema with media cost column docs, security-review with save_to jailed-write checklist, and roadmap current/deferred/phase-1 files with P4 landing status and deferred host-wiring obligations.

Sequence Diagrams

sequenceDiagram
  participant Engine
  participant applySaveTo
  participant resolveTemplate
  participant collectDurableMediaHandles
  participant MediaWritePort
  participant MediaReferencePort
  participant BudgetGovernor

  rect rgba(100, 149, 237, 0.5)
    note over Engine,BudgetGovernor: Pre-egress media cost check
    Engine->>BudgetGovernor: checkPreEgress(model, maxTokens, mediaUnitsEstimate)
    BudgetGovernor-->>Engine: allow | warn | fail
  end

  rect rgba(60, 179, 113, 0.5)
    note over Engine,MediaReferencePort: Post-turn: save_to and media reference lifecycle
    Engine->>applySaveTo: completed executor result
    applySaveTo->>resolveTemplate: save_to template + run.id scope
    resolveTemplate-->>applySaveTo: resolved relative path
    applySaveTo->>collectDurableMediaHandles: de-inlined output value
    collectDurableMediaHandles-->>applySaveTo: DurableMediaMeta[]
    applySaveTo->>MediaWritePort: write(relativePath, bytes, signal)
    MediaWritePort-->>applySaveTo: MediaWriteResult
    applySaveTo-->>Engine: NodeSuccess | NodeFailure
    Engine->>MediaReferencePort: recordRunMedia(runId, meta)
    Engine->>MediaReferencePort: reclaimRun(runId) on terminal
  end
Loading
sequenceDiagram
  participant LLM
  participant readMediaTool
  participant scopeSetIncludes
  participant validateByteRange
  participant MediaReadAccess
  participant bounding_toText

  LLM->>readMediaTool: dispatch(handle, start?, end?)
  readMediaTool->>MediaReadAccess: describe(handle)
  MediaReadAccess-->>readMediaTool: MediaHandleInfo | undefined
  readMediaTool->>scopeSetIncludes: allowedScopes vs requestingScope
  scopeSetIncludes-->>readMediaTool: granted | ToolPolicyError
  readMediaTool->>validateByteRange: (start, end, byteLength)
  validateByteRange-->>readMediaTool: valid | ToolArgsInvalidError
  readMediaTool->>MediaReadAccess: readRange(handle, range)
  MediaReadAccess-->>readMediaTool: bytes
  readMediaTool-->>LLM: MediaPart{mimeType, base64}
  LLM->>bounding_toText: build summary
  bounding_toText->>bounding_toText: redactInlineMediaForText (remove base64)
  bounding_toText-->>LLM: byte-free descriptor
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • HodeTech/Relavium#7: This PR extends the CostTracker/ModelPricing foundation from that PR by adding mediaOutputRates and a mediaCost disjoint addend.
  • HodeTech/Relavium#11: This PR wires outputModalities/mediaUnitsEstimate through the LLM request and pre-egress budget seam shapes introduced in that PR.
  • HodeTech/Relavium#33: This PR extends the deInlineMedia/MediaStore plumbing from that PR by adding save_to byte writing and media-reference recording/reclamation.

Poem

🐇 Hoppity-hop through frames of light,
The rabbit jails each byte just right—
run.id resolves, the scope is clear,
No base64 shall leak from here!
Media costs are tallied neat,
Phase 2 is now a tasty treat. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change—completing media access and spend governance (1.AF P4)—matching the PR's primary objective and scope.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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 remaining Phase-1 engine media plumbing (1.AF P4), introducing the read_media built-in tool with scope-set authorization, the save_to write port with a fail-closed filesystem jail, terminal-state media reference reclamation, and catalog-aware workflow validation. Feedback on the changes highlights two key improvements: adding optional chaining to prevent a potential TypeError when accessing model capabilities in the catalog validator, and explicitly handling Map and Set instances in the inline media redactor to avoid data loss during text serialization.

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.

if (caps === undefined) {
continue; // unresolvable model — defer to the runtime FallbackChain pre-skip (never a silent drop)
}
if (!isOutputComboSupported(caps.media.outputCombinations, node.output_modalities)) {

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

If caps.media is undefined or null (for example, in older or custom model definitions), accessing caps.media.outputCombinations will throw a TypeError. Using optional chaining prevents potential runtime crashes.

Suggested change
if (!isOutputComboSupported(caps.media.outputCombinations, node.output_modalities)) {
if (!isOutputComboSupported(caps.media?.outputCombinations ?? [], node.output_modalities)) {

Comment on lines +80 to +104
function redactInlineMediaForText(value: unknown, seen: WeakSet<object>): unknown {
if (typeof value === 'string') {
return isBase64DataUri(value) ? '[base64 data URI omitted]' : value;
}
if (typeof value !== 'object' || value === null) {
return value;
}
if (seen.has(value)) {
return '[cyclic]';
}
seen.add(value);
if (Array.isArray(value)) {
return value.map((item) => redactInlineMediaForText(item, seen));
}
const record = value as Record<string, unknown>;
if (isCanonicalBase64Source(record)) {
const data = record['data'];
return { kind: 'base64', bytes: typeof data === 'string' ? data.length : 0 }; // drop the bytes
}
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(record)) {
out[key] = redactInlineMediaForText(item, seen);
}
return out;
}

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

The redactInlineMediaForText function does not handle Map and Set instances. Since pushMediaWalkChildren explicitly supports walking Map and Set objects, these types can be present in the tool results. If they are not handled here, they will be treated as plain records, which converts them to empty objects {} and results in data loss in the text summary/spill/preview. Handling Map and Set explicitly ensures their contents are correctly redacted and preserved.

function redactInlineMediaForText(value: unknown, seen: WeakSet<object>): unknown {
  if (typeof value === 'string') {
    return isBase64DataUri(value) ? '[base64 data URI omitted]' : value;
  }
  if (typeof value !== 'object' || value === null) {
    return value;
  }
  if (seen.has(value)) {
    return '[cyclic]';
  }
  seen.add(value);
  if (Array.isArray(value)) {
    return value.map((item) => redactInlineMediaForText(item, seen));
  }
  if (value instanceof Map) {
    const out = new Map();
    for (const [key, item] of value.entries()) {
      out.set(redactInlineMediaForText(key, seen), redactInlineMediaForText(item, seen));
    }
    return out;
  }
  if (value instanceof Set) {
    const out = new Set();
    for (const item of value.values()) {
      out.add(redactInlineMediaForText(item, seen));
    }
    return out;
  }
  const record = value as Record<string, unknown>;
  if (isCanonicalBase64Source(record)) {
    const data = record['data'];
    return { kind: 'base64', bytes: typeof data === 'string' ? data.length : 0 }; // drop the bytes
  }
  const out: Record<string, unknown> = {};
  for (const [key, item] of Object.entries(record)) {
    out[key] = redactInlineMediaForText(item, seen);
  }
  return out;
}

@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: 6

🧹 Nitpick comments (4)
packages/db/src/media-reference-store.test.ts (1)

86-101: ⚡ Quick win

Add a regression case for “reclaimed then re-recorded” handles.

The suite currently validates reclaim behavior, but not reintroduction of the same handle afterward. Adding that case will lock the lifecycle contract and prevent silent authz regressions.

✅ Suggested test extension
   it('reclaimExpired soft-deletes only zero-ref objects past the grace window (D11 GC)', () => {
@@
     expect(store.reclaimExpired(0)).toEqual([HANDLE]);
     expect(store.describe(HANDLE)).toBeUndefined(); // soft-deleted
     expect(store.reclaimExpired(0)).toEqual([]); // idempotent — already deleted
+
+    // Re-recording the same content-addressed handle should revive it for describe/authz.
+    record();
+    store.addReference(HANDLE, 'session', 's2');
+    expect(store.describe(HANDLE)?.allowedScopes).toEqual([{ kind: 'session', id: 's2' }]);
   });
🤖 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/db/src/media-reference-store.test.ts` around lines 86 - 101, The
test validates the reclaim behavior and idempotency but does not verify what
happens when a reclaimed handle is re-recorded. After the final
expect(store.reclaimExpired(0)).toEqual([]) assertion that confirms idempotency,
add a regression case by re-recording the same HANDLE (call record() again),
then verify that store.describe(HANDLE) returns a defined value again and that
the handle can re-enter the normal lifecycle. This locks the contract and
prevents silent authorization regressions when handles transition from reclaimed
state back into active use.
packages/core/src/tools/builtins.test.ts (1)

373-375: ⚡ Quick win

Replace unsafe type assertion with a runtime type guard.

Line 374 uses an unsafe as cast; narrow err with instanceof before reading .reason.

As per coding guidelines, **/*.{ts,tsx} requires “no any types or unsafe as casts” and to “Use type guards instead of unsafe type assertions.”

Suggested change
     expect(err).toBeInstanceOf(ToolPolicyError);
-    expect((err as ToolPolicyError).reason).toBe('media_scope_denied');
+    if (!(err instanceof ToolPolicyError)) {
+      throw err;
+    }
+    expect(err.reason).toBe('media_scope_denied');
🤖 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/core/src/tools/builtins.test.ts` around lines 373 - 375, Replace the
unsafe type assertion `(err as ToolPolicyError).reason` with a proper runtime
type guard. Add an `instanceof ToolPolicyError` check to narrow the type of err
before accessing the `.reason` property. This ensures type safety without using
the unsafe `as` cast, following the coding guidelines that require type guards
instead of unsafe type assertions.

Source: Coding guidelines

packages/core/src/tools/bounding.ts (1)

94-101: ⚡ Quick win

Replace the unsafe type assertion with a type guard.

Line 94 uses value as Record<string, unknown>, which violates the strict TypeScript rule for unsafe assertions.

♻️ Suggested refactor
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null;
+}
+
 function redactInlineMediaForText(value: unknown, seen: WeakSet<object>): unknown {
   if (typeof value === 'string') {
     return isBase64DataUri(value) ? '[base64 data URI omitted]' : value;
   }
-  if (typeof value !== 'object' || value === null) {
+  if (!isRecord(value)) {
     return value;
   }
   if (seen.has(value)) {
     return '[cyclic]';
   }
   seen.add(value);
   if (Array.isArray(value)) {
     return value.map((item) => redactInlineMediaForText(item, seen));
   }
-  const record = value as Record<string, unknown>;
+  const record = value;

As per coding guidelines, “TypeScript-first, strict. No any, no unsafe as. Use type guards instead of unsafe type assertions.”

🤖 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/core/src/tools/bounding.ts` around lines 94 - 101, The unsafe type
assertion `value as Record<string, unknown>` on line 94 violates strict
TypeScript guidelines. Replace this assertion with a proper type guard function
that validates whether the value is actually a Record before proceeding with
operations like isCanonicalBase64Source check and Object.entries iteration.
Create a type guard that checks if value is an object type, then use that guard
to conditionally execute the Record-related operations instead of relying on the
unsafe as keyword.

Source: Coding guidelines

packages/llm/src/pricing.ts (1)

50-54: ⚡ Quick win

Use a modality-derived mapped type for mediaOutputRates to prevent key drift.

Hard-coding modality keys here can silently diverge from the shared canonical media modality set over time. A mapped type keeps this contract synchronized at compile time.

♻️ Proposed refactor
+import type { MediaBilledModality } from '`@relavium/shared`';
...
-  readonly mediaOutputRates?: {
-    readonly image?: number;
-    readonly audio?: number;
-    readonly video?: number;
-  };
+  readonly mediaOutputRates?: Partial<Record<MediaBilledModality, number>>;
🤖 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/pricing.ts` around lines 50 - 54, The mediaOutputRates
property in the pricing.ts file uses hard-coded modality keys (image, audio,
video) that can drift from the canonical media modality set over time. Replace
the explicit object type definition for mediaOutputRates with a mapped type that
derives from a shared canonical modalities type, ensuring the keys stay
synchronized at compile time. This mapped type approach will automatically
reflect any changes to the canonical modality set without requiring manual
updates to this interface.
🤖 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 `@packages/core/src/engine/agent-runner.ts`:
- Around line 137-140: The isBilledModality function uses an unsafe cast `as
readonly string[]` that removes literal type information from
MEDIA_BILLED_MODALITIES, violating TypeScript safety guidelines. Replace the
current implementation by creating a ReadonlySet from MEDIA_BILLED_MODALITIES
and using its .has() method instead of .includes() with the cast. This maintains
the literal types from the const-asserted array and eliminates the unsafe type
casting.

In `@packages/core/src/tools/bounding.ts`:
- Around line 115-123: The redaction call to redactInlineMediaForText is
currently executed outside the try-catch block, which means any thrown errors
from getters or proxies in the tool output during redaction will not be caught
and will cause the function to fail ungracefully. Move the logic that assigns to
the safe variable (the conditional check for containsInlineMediaBytes and the
call to redactInlineMediaForText) inside the try block so that any throws during
the redaction traversal are caught by the existing error handler and allow the
function to degrade safely to the "[unserializable]" fallback.

In `@packages/db/drizzle/0003_cloudy_wind_dancer.sql`:
- Around line 1-3: The Drizzle migration artifacts in the
0003_cloudy_wind_dancer.sql file are out of sync with the current schema
definition in src/schema.ts, causing CI to fail with schema-drift errors.
Regenerate all Drizzle migration files by running the database generation
command for the `@relavium/db` package, then commit both the resulting SQL
migration files and the Drizzle metadata files together to ensure the migration
state matches the current schema definition.

In `@packages/db/src/media-reference-store.ts`:
- Around line 86-89: The issue is that when a handle is re-recorded (already
exists in the database), the onConflictDoUpdate call on mediaObjects.handle only
refreshes the lastReferencedAt timestamp but does not clear the deletedAt field
that may have been set during garbage collection soft-deletion. This causes
describe() to keep returning undefined for reintroduced content. In the set
object of the onConflictDoUpdate call, add deletedAt: null alongside the
lastReferencedAt: ts update to ensure that soft-deleted handles are properly
restored when they are re-recorded.

In `@packages/db/src/schema.ts`:
- Around line 111-113: The monetary rate columns mediaImageCostMicrocents,
mediaAudioCostMicrocents, and mediaVideoCostMicrocents are defined as integer
fields without non-negative constraints, allowing them to persist negative
values which can undermine budget enforcement. Add a non-negative constraint
(such as .unsigned() or a check constraint ensuring values are greater than or
equal to 0) to each of these three integer column definitions in the schema.
Apply this constraint to all instances of these columns throughout the schema,
including the sections mentioned in lines 125-133.

In `@packages/shared/src/config.ts`:
- Around line 104-107: The ADR relative link path in the JSDoc comment block for
the media-output unit-count default is rooted incorrectly. Update the link
reference `../decisions/0044-media-access-governance-read-media-save-to-cost.md`
to use the correct relative path from the file location
packages/shared/src/config.ts to the actual decisions directory location.
Traverse up the appropriate number of directory levels from src/ to reach the
project root and then navigate to the decisions folder, ensuring the relative
link properly points to the ADR file.

---

Nitpick comments:
In `@packages/core/src/tools/bounding.ts`:
- Around line 94-101: The unsafe type assertion `value as Record<string,
unknown>` on line 94 violates strict TypeScript guidelines. Replace this
assertion with a proper type guard function that validates whether the value is
actually a Record before proceeding with operations like isCanonicalBase64Source
check and Object.entries iteration. Create a type guard that checks if value is
an object type, then use that guard to conditionally execute the Record-related
operations instead of relying on the unsafe as keyword.

In `@packages/core/src/tools/builtins.test.ts`:
- Around line 373-375: Replace the unsafe type assertion `(err as
ToolPolicyError).reason` with a proper runtime type guard. Add an `instanceof
ToolPolicyError` check to narrow the type of err before accessing the `.reason`
property. This ensures type safety without using the unsafe `as` cast, following
the coding guidelines that require type guards instead of unsafe type
assertions.

In `@packages/db/src/media-reference-store.test.ts`:
- Around line 86-101: The test validates the reclaim behavior and idempotency
but does not verify what happens when a reclaimed handle is re-recorded. After
the final expect(store.reclaimExpired(0)).toEqual([]) assertion that confirms
idempotency, add a regression case by re-recording the same HANDLE (call
record() again), then verify that store.describe(HANDLE) returns a defined value
again and that the handle can re-enter the normal lifecycle. This locks the
contract and prevents silent authorization regressions when handles transition
from reclaimed state back into active use.

In `@packages/llm/src/pricing.ts`:
- Around line 50-54: The mediaOutputRates property in the pricing.ts file uses
hard-coded modality keys (image, audio, video) that can drift from the canonical
media modality set over time. Replace the explicit object type definition for
mediaOutputRates with a mapped type that derives from a shared canonical
modalities type, ensuring the keys stay synchronized at compile time. This
mapped type approach will automatically reflect any changes to the canonical
modality set without requiring manual updates to this interface.
🪄 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: 3ba5d197-b0c8-478e-9859-db16f2c01d63

📥 Commits

Reviewing files that changed from the base of the PR and between d0faf38 and e6e8545.

📒 Files selected for processing (51)
  • docs/reference/contracts/config-spec.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
  • docs/standards/security-review.md
  • packages/core/src/dag.test.ts
  • packages/core/src/engine/agent-runner.test.ts
  • packages/core/src/engine/agent-runner.ts
  • packages/core/src/engine/agent-turn.ts
  • packages/core/src/engine/budget-governor.test.ts
  • packages/core/src/engine/budget-governor.ts
  • packages/core/src/engine/engine.test.ts
  • packages/core/src/engine/engine.ts
  • packages/core/src/engine/execution-host.ts
  • packages/core/src/index.ts
  • packages/core/src/interpolation/analyze.test.ts
  • packages/core/src/interpolation/references.test.ts
  • packages/core/src/interpolation/references.ts
  • packages/core/src/interpolation/resolve.test.ts
  • packages/core/src/interpolation/resolve.ts
  • packages/core/src/interpolation/scope.ts
  • packages/core/src/tools/bounding.test.ts
  • packages/core/src/tools/bounding.ts
  • packages/core/src/tools/builtins.test.ts
  • packages/core/src/tools/builtins.ts
  • packages/core/src/tools/errors.ts
  • packages/core/src/tools/registry.test.ts
  • packages/core/src/tools/types.ts
  • packages/core/src/validate-catalog.test.ts
  • packages/core/src/validate-catalog.ts
  • packages/db/drizzle/0003_cloudy_wind_dancer.sql
  • packages/db/drizzle/meta/0003_snapshot.json
  • packages/db/drizzle/meta/_journal.json
  • packages/db/src/index.ts
  • packages/db/src/media-reference-store.test.ts
  • packages/db/src/media-reference-store.ts
  • packages/db/src/media-write.test.ts
  • packages/db/src/media-write.ts
  • packages/db/src/schema.ts
  • packages/llm/src/budget-estimator.test.ts
  • packages/llm/src/budget-estimator.ts
  • packages/llm/src/cost-tracker.test.ts
  • packages/llm/src/cost-tracker.ts
  • packages/llm/src/index.ts
  • packages/llm/src/pricing.ts
  • packages/shared/src/config.test.ts
  • packages/shared/src/config.ts
  • packages/shared/src/content.test.ts
  • packages/shared/src/content.ts

Comment thread packages/core/src/engine/agent-runner.ts
Comment thread packages/core/src/tools/bounding.ts Outdated
Comment on lines +1 to +3
ALTER TABLE `model_catalog` ADD `media_image_cost_microcents` integer;--> statement-breakpoint
ALTER TABLE `model_catalog` ADD `media_audio_cost_microcents` integer;--> statement-breakpoint
ALTER TABLE `model_catalog` ADD `media_video_cost_microcents` integer; No newline at end of file

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 | 🟠 Major | ⚡ Quick win

Regenerate and recommit Drizzle artifacts — current migration set is out of sync.

CI is failing with explicit schema-drift errors for @relavium/db. Please rerun generation and commit the resulting SQL/meta files together so migration state matches src/schema.ts and derived checks.

pnpm --filter `@relavium/db` db:generate
🧰 Tools
🪛 GitHub Actions: CI / 2_lint · typecheck · test.txt

[error] 1-1: Migration out of sync: The committed @relavium/db migration is out of sync with src/schema.ts (or an upstream @relavium/shared enum the checks derive from). Regenerate and commit: pnpm --filter @relavium/db db:generate

🪛 GitHub Actions: CI / lint · typecheck · test

[error] 1-1: The committed @relavium/db migration is out of sync with src/schema.ts (or an upstream @relavium/shared enum the CHECKs derive from). Regenerate and commit: pnpm --filter @relavium/db db:generate

🤖 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/db/drizzle/0003_cloudy_wind_dancer.sql` around lines 1 - 3, The
Drizzle migration artifacts in the 0003_cloudy_wind_dancer.sql file are out of
sync with the current schema definition in src/schema.ts, causing CI to fail
with schema-drift errors. Regenerate all Drizzle migration files by running the
database generation command for the `@relavium/db` package, then commit both the
resulting SQL migration files and the Drizzle metadata files together to ensure
the migration state matches the current schema definition.

Source: Pipeline failures

Comment thread packages/db/src/media-reference-store.ts Outdated
Comment thread packages/db/src/schema.ts
Comment on lines +111 to +113
mediaImageCostMicrocents: integer('media_image_cost_microcents'),
mediaAudioCostMicrocents: integer('media_audio_cost_microcents'),
mediaVideoCostMicrocents: integer('media_video_cost_microcents'),

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 | 🟠 Major | ⚡ Quick win

Add non-negative DB checks for the new media rate columns.

media_*_cost_microcents are monetary rate fields, but they currently allow negative values. Negative persisted rates can underprice projected media cost and weaken budget enforcement.

Suggested fix
 export const modelCatalog = sqliteTable(
   'model_catalog',
   {
@@
     mediaImageCostMicrocents: integer('media_image_cost_microcents'),
     mediaAudioCostMicrocents: integer('media_audio_cost_microcents'),
     mediaVideoCostMicrocents: integer('media_video_cost_microcents'),
@@
   },
   (t) => [
+    check(
+      'model_catalog_media_image_cost_non_negative',
+      sql`${t.mediaImageCostMicrocents} is null or ${t.mediaImageCostMicrocents} >= 0`,
+    ),
+    check(
+      'model_catalog_media_audio_cost_non_negative',
+      sql`${t.mediaAudioCostMicrocents} is null or ${t.mediaAudioCostMicrocents} >= 0`,
+    ),
+    check(
+      'model_catalog_media_video_cost_non_negative',
+      sql`${t.mediaVideoCostMicrocents} is null or ${t.mediaVideoCostMicrocents} >= 0`,
+    ),
     uniqueIndex('idx_model_catalog_provider_model')
       .on(t.providerId, t.modelId)
       .where(sql`${t.deletedAt} is null`),

Also applies to: 125-133

🤖 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/db/src/schema.ts` around lines 111 - 113, The monetary rate columns
mediaImageCostMicrocents, mediaAudioCostMicrocents, and mediaVideoCostMicrocents
are defined as integer fields without non-negative constraints, allowing them to
persist negative values which can undermine budget enforcement. Add a
non-negative constraint (such as .unsigned() or a check constraint ensuring
values are greater than or equal to 0) to each of these three integer column
definitions in the schema. Apply this constraint to all instances of these
columns throughout the schema, including the sections mentioned in lines
125-133.

Comment thread packages/shared/src/config.ts
cemililik and others added 2 commits June 19, 2026 20:16
…-valid only)

Verified each external/Sonar finding against current code; fixed the still-valid
ones (minimal), skipped the rest with reasons below.

Fixed:
- [correctness] media-reference-store recordObject now clears deleted_at on a
  re-record (onConflictDoUpdate set deletedAt:null), so a GC-soft-deleted handle
  is RESURRECTED when the same content-addressed bytes are produced again —
  previously describe() stayed undefined and read_media denied live re-introduced
  content. + a re-record-after-reclaim regression test.
- [robustness] bounding.toText now runs the media-redaction INSIDE the try, so a
  throwing getter/proxy during the redaction walk degrades to '[unserializable]'
  instead of escaping.
- [TS] agent-runner isBilledModality uses a ReadonlySet<string> (drops the
  `as readonly string[]` cast); bounding redactor uses an isRecord guard (drops
  the `as Record` cast); builtins.test narrows via `instanceof ToolPolicyError`
  (drops the `as` cast).
- [drift-proof] pricing.ts mediaOutputRates is now a mapped type keyed by
  MediaBilledModality (was a hand-maintained image/audio/video literal).
- [docs] config.ts ADR-0044 link fixed (../decisions → ../../../docs/decisions).
- [Sonar] references RUN_ID regex uses \w; media-write.test uses String.raw for
  the Windows/UNC paths; content.test sort takes an explicit localeCompare
  comparator (S2871).

Skipped (verified not actionable):
- Drizzle migration "drift": `pnpm --filter @relavium/db db:generate` reports
  "No schema changes" — 0003 is in sync, no CI drift (false positive).
- schema.ts media-rate columns non-negative constraint: the sibling money columns
  (input/output/cached microcents) carry no such CHECK either; rates are
  catalog-seeded (verified, never negative), not user input; `.unsigned()` is not
  a SQLite/Drizzle option — adding a check to only the 3 new columns would be
  inconsistent and needs a migration for no real exploit surface.
- validate-catalog caps.media optional chaining: CapabilityFlags requires `media`
  (Zod MediaCapabilitiesSchema at the seam), so it cannot be undefined for a valid
  catalog result; `?.` would only mask a host contract violation.
- bounding redactor Map/Set handling: a Map/Set collapses to `{}` exactly as
  JSON.stringify renders it (non-serializable) — no base64 leaks and no regression
  vs the un-redacted JSON path, so no change needed for the I3 purpose.

Toolchain: lint/typecheck/test/build 16/16 green (786 core tests); prettier
clean; Leakwatch 0.

Refs: ADR-0044, ADR-0042

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

A second multi-agent review pass (Sonnet, 9 dimensions, double-verified) on top
of the first review's fixes — 0 blockers/highs in reachable code. Salvaged its
findings and applied the still-valid ones (minimal); deferred a read_media
result-contract cluster to 1.AH and skipped the rest with reasons.

Fixed:
- [I3] bounding redaction now strips RAW BINARY BUFFERS (Uint8Array/ArrayBuffer)
  too — a buffer tool result would otherwise JSON-serialize its decimal byte
  values into the agent:tool_result.outputSummary/spill. The walk now runs for
  every non-string result (so a buffer-only result is caught) and uses a
  PLAIN-object guard so Date/Map/Set/class results render natively (not collapsed
  to {}); the cycle-safe walk also makes a circular result serialize ('[cyclic]').
- [schema] read_media 0-byte whole-read now returns a HANDLE source, not
  `{ kind:'base64', data:'' }` (which violates the base64 nonEmptyString contract
  and breaks deInlineMedia's empty-decode).
- [defense] read_media `handle` arg is validated against MEDIA_HANDLE_PATTERN at
  parse (engine-pure structural check); durableMediaMetaOf also rejects a
  non-pattern ref before recording it.
- [governance] the FallbackChain per-attempt budget hook now carries the media
  estimate (outputModalities/mediaUnitsEstimate) so a cross-provider FAILOVER is
  media-priced, not token-only (ADR-0044 §3) — was silently dropped.
- [scalability] reclaimExpired chunks its `handle IN (…)` UPDATE under SQLite's
  999-bound-parameter floor (a large GC sweep would otherwise throw at runtime).
- [parse] RUN_ID lookahead also excludes `-` so `{{run.id-x}}` is `unknown`, not
  a mis-parsed kind:'run'.
- [bound] ScopeSchema.id gains .max(255) (matches the mimeType/name convention).
- [observability] redactor marker field renamed bytes→base64Length (it is the
  base64 char length, ~1.33× the byte count); start/end LLM-visible schema gains
  minimum:0.
- Tests: denied-scope on a 0-byte handle (authz BEFORE the short-circuit),
  malformed-handle parse rejection, array-of-media + binary-buffer + Date
  redaction, symlink-jail errors asserted as reason-only MediaWriteError (no path
  leak), save_to template-failure message leaks no reference name, run.id
  trailing-path → unresolved_reference; corrected a misleading analyze.test comment.

Deferred to 1.AH (recorded in deferred-tasks §1.AF P4) — the read_media result
must carry a HANDLE (not inline base64) to satisfy LlmMessageSchema on a
multi-turn call; narrow MediaReadAccess.readRange + thread AbortSignalLike — all
co-decided with the 1.AH host wiring (read_media is inert today). Plus the
budget-governor non-zero-media-estimate test gap.

Skipped (verified): drift (db:generate = no changes), the `as RunEventDraft`
cast (pre-existing/merged, Zod re-validates at #bus.next), the soft-delete
"orphan media_references" claim (a reclaimed handle is zero-ref by construction,
so no rows exist to orphan), a plain non-data:-URI base64 string (undetectable
without false positives; deInlineMedia is the primary gate), and unbounded
transcript / cyclic-marker cosmetics (by-design).

Toolchain: lint/typecheck/test/build 16/16 green; prettier clean; Leakwatch 0.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cemililik cemililik merged commit ebba5ce into main Jun 19, 2026
9 checks passed
@sonarqubecloud

Copy link
Copy Markdown

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