DEV Community

Cover image for Your Provenance Vector Dies at the Storage Boundary
Sergei Parfenov
Sergei Parfenov

Posted on

Your Provenance Vector Dies at the Storage Boundary

Type-level gates and memory compaction

Last post I argued that agent trust should be a typed provenance vector: carry what-degraded-and-how alongside each result, propagate it, let each consumer apply its own policy. The comments agreed on the model and then immediately found the two places it breaks in the real world. Both are load-bearing, both were things I hand-waved, and this post is about them.

  • mote asked what happens when the agent runs 500 steps and the vector no longer fits in the context window.
  • Mykola said the quiet part louder: "you can build a perfect trust lattice but most agents just act on output without checking provenance. The hard part is enforcement, not the model."

Both are right, and together they name the two ways a provenance vector dies in production: nobody reads it, or it can't survive being stored. One problem is about enforcement, the other about persistence.

TL;DR — Two failure modes kill a provenance vector in production. Enforcement: if acting on a value doesn't require passing through the gate, developers (and models writing tool calls) will skip it — so make the unsafe path unrepresentable via types, not discipline. Persistence: on long-horizon agents the vector must survive compression to fit bounded memory, and naive summarization washes out exactly the axes you need — so compress structurally (per-axis, lossless scores + lossy lineage), not as prose.

Problem 1: enforcement, or the vector nobody reads

Mykola's point is the one that should scare you, because it's true of almost every "add metadata to make it safer" scheme: the metadata is optional, so under deadline it gets skipped. You can ship a beautiful Provenance type and six months later find that the payment path reads result.value and never touches result.provenance. The lattice was perfect. Nobody consulted it.

The fix is not "remember to check." Discipline doesn't scale and it definitely doesn't survive a model writing its own tool calls. The fix is to make acting without checking something the code physically cannot express.

This is a solved problem in a neighboring field, and it's worth stealing wholesale. Capability-based security has done this for decades: authority is an unforgeable token you must hold a reference to — you can't perform the action without possessing the capability, and possession is the check. Recent work brings this into static types explicitly: track the capability in the type system, and the absence of it in a function's type guarantees, at compile time, that the function can't perform the guarded action. The safety isn't a runtime assertion you might forget — it's a property of what typechecks.

Applied to provenance, the move is: the irreversible action can't accept a raw value, only a gated one.

from typing import Generic, TypeVar, NoReturn
T = TypeVar("T")

class Provenanced(Generic[T]):
    """A value you cannot use for a side effect without unwrapping —
    and the ONLY unwrap path runs the gate."""
    def __init__(self, value: T, prov: Provenance):
        self._value = value
        self._prov = prov

    def unwrap_for(self, action: "Policy") -> T:
        decision = gate(action, self._prov)
        if decision != "proceed":
            raise ProvenanceViolation(decision, self._prov)  # refetch / escalate / ...
        return self._value

# the side-effecting function's SIGNATURE refuses raw values:
def charge_card(amount: Provenanced[Money], policy: Policy) -> Receipt:
    money = amount.unwrap_for(policy)   # the only way to get the Money out
    ...
Enter fullscreen mode Exit fullscreen mode

Now "charge the card without checking provenance" doesn't fail code review — it doesn't typecheck. There is no path from a raw Money to charge_card, because the signature demands Provenanced[Money], and the only way to extract the value runs the gate. You've moved the enforcement from the developer's memory into the type system. It's the same trick as idempotency keys from two posts ago: don't ask people to remember the safe thing, make the unsafe thing unrepresentable.

The honest limit (which a commenter will rightly raise, so I'll raise it first): this holds at the framework boundary, in typed code you control. The moment your agent writes free-form tool calls — the model generating Python that calls your API directly — it can simply not use the wrapper, and you're back to enforcement-by-hope. For that case the type system can't reach, so enforcement has to drop to the infrastructure layer: the side-effecting tools sit behind a proxy that refuses any call whose payload doesn't carry valid provenance. You lose compile-time guarantees and get runtime rejection instead — worse, but still "structurally can't skip it" rather than "please remember." The principle survives even when the mechanism changes: enforcement lives in something the actor can't route around, never in something it's asked to honor.

Problem 2: provenance that survives compression

mote's problem is deeper and I didn't have an answer in the thread, so I went and found one. Here's the setup: a long-horizon agent — mote's case is literally robots on edge hardware with a hard context ceiling — can't hold a growing provenance graph in working memory across 500 steps. It has to compress. And the standard compression move, summarize-history-into-prose, is catastrophic for provenance specifically, because summarization is lossy in an uncontrolled way — it'll happily drop "step 47 ran on a stale cache" to save tokens, and that's the one fact a downstream gate needed.

This isn't hypothetical. The field now attributes the majority of enterprise agent failures to context drift and memory loss during multi-step reasoning — not to hitting the context limit, but to the quality degradation on the way there. And there's a subtler trap the RL-agent researchers named: compression credit is causally entangled — the same downstream failure needs opposite explanations depending on whether the bad state came from a tool or from memory. If your compression flattens that distinction, you can't even diagnose what broke.

So the naive answer — "summarize the provenance too" — reintroduces the exact scalar-collapse problem from the last post, now smuggled in through the storage layer. A summary is an average wearing a trench coat.

The better answer comes from a simple observation: the axes have different compression economics, so don't compress them uniformly.

  • Scores compress to almost nothing, losslessly. A per-axis float — freshness: 0.2, capability: 0.6 — is a handful of numbers. Even across 500 steps, if you keep only the running minimum per axis (which is what the gate reads anyway; recall the min from last post), that's constant size regardless of history length. You never need to compress the scores, because min-reduction already bounds them.
  • Lineage is what explodes, and lineage is what you can afford to lose. The tainted_by sets — which exact steps degraded each axis — grow with the trajectory. But for the gate decision, you usually don't need the full ancestry; you need "is any unverified degraded step still on the live path." So this is the part you lossy-compress: keep the axis scores whole, summarize the lineage behind a pointer, and accept that you lose "which exact step" while keeping "how degraded, per axis."

This maps onto where the research is heading. The most promising long-horizon approaches have stopped treating the trajectory as prose to be summarized and started treating it as a typed dependency graph the agent annotates as it works, with a deterministic eviction policy that walks the graph when the token budget blows — explicitly to avoid the four pathologies of prose compaction: unpredictable lossiness, structural destruction, blocking cost, and compression-induced hallucination. A typed provenance vector is that annotation. The eviction policy for provenance is: evict lineage detail, never evict axis scores.

There's one more axis this forces you to add, and it's almost funny: compression is itself a degradation source. A vector reconstructed from a lossy summary is less trustworthy than one carried whole — so "this provenance was reconstructed across a storage boundary" is a real provenance fact that deserves its own axis. reconstruction: 0.8 means "these scores survived a compaction; treat the lineage as approximate." The provenance system has to describe its own lossiness. Turtles, but only two deep.

Why this keeps being a security problem in disguise

Every post in this series has ended up borrowing from security, and this one makes the reason explicit. Traditional taint tracking assumes deterministic program states and exact data-flow: memory locations, registers, string matches. LLM agents break all of that — untrusted content gets rewritten, summarized, and used to choose later actions, so "did this bad input reach that sink" is a question about semantic and causal influence, not byte-level flow. The agent security researchers building taint trackers for exactly this case had to redefine propagation to include semantic transformation and cross-session persistence through memory — which is the same two problems this post is about (enforcement and persistence), arrived at from the attack side instead of the reliability side.

That convergence is the tell. When the reliability people and the security people independently reinvent the same structure — unforgeable gating plus provenance that survives memory — it's because it's the actual shape of the problem, not a preference.

Where the series stands

Four posts, one arc:

  1. Availability — agents fail on capacity (rate limits), not reasoning.
  2. Correctness — the capacity fixes buy uptime by acting on unearned output; you need correct uptime.
  3. The model — trust isn't a scalar; it's a typed provenance vector with policy at the consumer.
  4. The reality (this one) — that vector only works if it's unskippable (enforcement by type/proxy) and survivable (structural compression, not prose).

The through-line, one more time: agent reliability is a provenance problem, and provenance is a solved discipline — capability security, data lineage, taint analysis — that we're re-deriving because the untraceable thing now acts, and acts through a bounded, forgetful, non-deterministic memory. The novelty isn't the primitives. It's that they now have to hold under compression and under a model that can route around anything you merely ask it to respect.

If you're building this: gate at a boundary the actor can't skip (type or proxy), compress scores losslessly and lineage lossily, and add a reconstruction axis the day your provenance crosses a storage line. Start there.


Credit, again, to the comment section that wrote the spec: **mote* (compression across the storage boundary, the edge/bounded-context framing that motivates the whole second half), Mykola Kondratiuk (enforcement is the hard part, not the model), plus Tae Kim, Nazar Boyko, Ken, and Ahmet Özel for sharpening the axis rules in the last thread. Open question for this one: has anyone actually run provenance across a compaction boundary in production and measured what the gate decisions do on the reconstructed vector versus the original? That's the experiment I don't have data for yet — and it's the one that decides whether any of this holds.*

Sources & further reading

Top comments (18)

Collapse
 
max_quimby profile image
Max Quimby

The enforcement framing is the part most "add metadata" schemes miss, and you nailed why: optional metadata is skipped metadata. The types-not-discipline move maps cleanly onto parse-don't-validate — if result.value isn't reachable without unwrapping through the gate, there's no "remember to check." A model writing its own tool calls can't skip a step that doesn't exist.

On persistence: we run long-horizon agents where compaction quietly eats exactly this kind of side-channel metadata. What worked for us was splitting the vector the way you describe — but making the lossy lineage a stable pointer into an append-only external log rather than inline prose. The load-bearing scores stay in-context and survive compression; the full lineage lives out-of-band and gets rehydrated only when a consumer actually needs to audit. Bounded in-context footprint without washing out the "why."

Question: when lineage has been compressed to a summary and a consumer's policy needs the detail to decide, do you re-expand from somewhere, or does the policy just degrade to treating that result as untrusted?

Collapse
 
p0rt profile image
Sergei Parfenov

the append-only external log with a stable pointer is cleaner than what i wrote — inline "summary behind a pointer" was vague, "pointer into an out-of-band log, rehydrate on audit" is the actual mechanism. stealing the framing.
to your question — i think there are two regimes and the design has to pick per axis:

rehydratable (your case): the lineage still exists out-of-band, so a consumer that needs detail pays a fetch to re-expand and decides on the full thing. cost is latency, not trust. this is strictly better and should be the default wherever the log is reachable.
truly gone (the edge/bounded case that started this — the external log isn't reachable from the robot, or the retention window rolled): then yes, the policy degrades to treating the result as untrusted on the axes that needed lineage, but — and this is the part that keeps it from being over-strict — not on the axes that only needed scores. freshness/capability gates still decide normally on the running-min scores; only the gates that specifically needed "which exact step" fall back to distrust. so it's not "untrusted result", it's "untrusted on the lineage-dependent axes only." the vector structure is what lets the degradation be partial instead of all-or-nothing.

which means the design rule is: put a gate's dependency on lineage vs scores explicitly in the policy, so when lineage is unreachable you know precisely which gates degrade and which don't. a gate that secretly needed lineage but wasn't marked is the one that'll silently over-trust when the log is gone.

Collapse
 
nazar-boyko profile image
Nazar Boyko

The reconstruction axis is my favorite move in this one, and it hides a small recursion worth poking at. If each compaction lowers reconstruction and you carry it with a running min like the other axes, then an agent that compacts fifty times over its life watches that score decay toward full distrust no matter how clean each single reconstruction was. That might be exactly right, since age really is a form of degradation, or it might mean reconstruction wants to be tracked per hop rather than reduced with a running min, so "survived one lossy hop recently" doesn't get permanently confused with "been through the wringer." On your open question about gate decisions on reconstructed versus original vectors, my hunch is the scores track closely and the gates that lean on lineage are where they split, since lineage is the axis you're deliberately dropping. Good series.

Collapse
 
p0rt profile image
Sergei Parfenov

this is a real bug in the axis and you caught it — reconstruction can't use running-min like the others, because min conflates two different things: "this hop was very lossy" and "there have been many hops." those need opposite handling. a single brutal compaction should tank the score; fifty clean ones shouldn't sum to the same distrust.
i think you're right that it wants per-hop tracking, but let me push one step further: reconstruction probably isn't one axis, it's two — fidelity (how lossy was the worst single hop, which does want a min, because one catastrophic compaction is a real floor) and age/hop-count (how many boundaries has this crossed, which wants a counter, not a min). collapsing them into one running-min score is exactly the scalar-collapse mistake from post 3, recreated inside the reconstruction axis. so the axis that describes lossy compression got compressed too lossily. which is funny in a way that's also slightly upsetting.
and yeah — your hunch on the open question matches mine: scores are cheap to preserve so they track, and the split shows up precisely on the lineage-dependent gates, because lineage is the thing i'm deliberately throwing away. if anyone's measured that in prod i'd still love the data, but the structure predicts it.

Collapse
 
kartik-nvjk profile image
Kartik N V J K

The per-axis compression idea, keeping the scores lossless and letting lineage go lossy, is a sharp way to stop naive summarization from washing out the axes you actually gate on. Making the unsafe path unrepresentable in the type system is the only enforcement I trust too, since a model writing tool calls will happily ignore a convention. Have you hit cases where two provenance axes are correlated enough that compressing them independently loses the interaction you needed?

Collapse
 
p0rt profile image
Sergei Parfenov

yeah, and it's the sharpest hole in the per-axis story. independent min-reduction treats the axes as orthogonal, and some degradations are only meaningful jointly — the interaction is the signal, and compressing each axis alone throws it away.

concrete case i hit: freshness and capability, together. a fresh answer from a weak fallback and a stale answer from the strong primary can land on the same two axis scores — say freshness 0.6, capability 0.6 in one, and 0.6/0.6 in the other after min-reduction — but they're not the same risk at all. "recent data, dumb model" fails differently from "smart model, old data," and a price calc cares enormously which one it is. min-per-axis flattens both into the same vector. the interaction — which axis was low when — is exactly what got washed out.

the honest fix isn't "add an interaction axis," because that's unbounded (every pair, then every triple). what's worked is cheaper: keep the axes independent for the common case, but when two axes are known to interact for a specific gate, that gate carries a small joint tag instead of reading the two scalars separately — "this value was (stale AND fallback) at step N," preserved as one fact, not two. so you pay the joint-preservation cost only on the gate that actually needs the interaction, and everything else stays cheap orthogonal min-reduction. it's the same move as the whole series really: don't pay for the expensive representation everywhere, pay for it exactly where a decision depends on it.

but "which axis pairs interact" is itself something you have to declare per gate, and i don't have a clean way to discover that automatically — right now it's "you find out the interaction mattered when a gate makes a wrong call, then you add the joint tag." reactive, not principled. if you've seen a way to know up front which axes are non-separable for a given decision, i'd take it — that's the part i can't do cleanly yet.

Collapse
 
motedb profile image
mote

Good to see the provenance vector series getting a proper treatment of the compression problem. The structural compression idea — keep scores whole, lossy-compress lineage — is the right move.

One thing worth adding to the "scores compress losslessly" point: whether that holds depends on your score schema. If you have a flat float per axis, yes, min-reduction gives you constant-size state. But if your provenance system grows axes over time — adding "freshness" after noticing the cache was stale, adding "reconstruction" after noticing compression artifacts — then the score vector itself is growing, and you haven't actually solved the compaction problem, you've just deferred it one layer. The right fix is a fixed, typed schema where every axis is declared upfront and the type system enforces that nothing new can be added without a migration. That's a data modeling constraint, not just a compression strategy.

On the moteDB angle: structured provenance lives in the same place as the agent's operational state, and the closer those two are — the less data movement between them — the harder it is for enforcement to be bypassed. If provenance is in a separate service with a different access path, there's always a way to skip it. If it's embedded in the same process as the agent's memory store, the only path to state is through the provenance layer. That's the architectural argument for putting provenance at the storage level rather than as a sidecar.

The open question from the comments still stands: has anyone run this in production across a compaction boundary and compared gate decisions on reconstructed vs. original vectors? That's the experiment that would validate the whole approach.

Collapse
 
p0rt profile image
Sergei Parfenov

this is the hole i left and you're right — "scores compress losslessly" quietly assumed a fixed axis schema, and if axes grow over time the score vector grows with them, so i deferred the compaction problem by one layer instead of solving it. the min-reduction gives constant size per axis; it says nothing about the number of axes, and i conflated the two.

and the fix being a data-modeling constraint rather than a compression one is the part i'd underweighted: axes declared upfront, typed, no new axis without a migration. which has a consequence i actually like — it forces axis addition to be a deliberate schema change with a version, not something a dev sprinkles in mid-project. that versioning is itself provenance: "this vector was written under axis-schema v3" tells a later consumer which axes it can even expect to find. schema evolution becomes another thing the vector has to carry, which is turtles again, but at least it's a bounded, versioned turtle instead of unbounded growth.

the storage-level argument is the cleanest case i've seen for it: if the only path to state runs through the provenance layer, enforcement isn't a convention, it's the topology. a sidecar with a separate access path always has a way around it. that's exactly the "make the unsafe path unrepresentable" idea from the enforcement half, but pushed down to where the data physically lives instead of into the type system — same principle, different layer. structurally can't skip it because there's no other door.
and yeah, the prod experiment still stands as the thing that would validate all of this — gate decisions on reconstructed vs original vectors across a real compaction boundary. i don't have that data. if moteDB's structure makes that measurable, i'd genuinely want to see the numbers — that's the experiment the whole series has been circling.

Collapse
 
voltagegpu profile image
VoltageGPU

Interesting take on the provenance vector's fragility at the storage boundary. In my work with VoltageGPU, I've seen how even minor mismatches in tensor metadata during checkpointing can break lineage tracking. It's a great reminder that compression isn't just about size — it has to preserve the structure that makes the data meaningful.

Collapse
 
alexshev profile image
Alex Shev

Provenance has to survive the boring boundaries: storage, cache, exports, and retries. If the trail disappears there, the system can sound confident while losing the reason it trusted the data.

Collapse
 
p0rt profile image
Sergei Parfenov

"the boring boundaries" is the right frame — storage, cache, exports, retries. nobody instruments those because none of them throw an error; the value just quietly crosses a line and loses its history on the way. and the boring boundaries are exactly where nobody's looking, which is why the confident-but-groundless output always seems to originate "from nowhere" — it originated at the boundary you didn't think needed provenance.

Collapse
 
alexshev profile image
Alex Shev

Yes. The export layer is where a lot of provenance quietly dies because everyone treats it as formatting, not as part of the trust model.

If a value leaves the system without its source, timestamp, and transformation path, the downstream consumer only sees confidence with no receipt.

Collapse
 
jugeni profile image
Mike Czerwinski

The reconstruction axis has a quiet authorship problem: the score is written by the same process that did the evicting. A downstream gate reads reconstruction: 0.8 as a fact about degradation, but it is the compressor's self-assessment of its own surgery. If the compaction step is the broken one, its confession is broken with it.

The way out is already in your stack. The eviction policy is deterministic, so reconstruction does not have to be an asserted float; it can be a recomputable function of the eviction log: nodes dropped, bytes dropped, policy version, hash of the evicted subgraph. A consumer that distrusts the score re-derives it. Same move as your unwrap gate: never trust the assertion, make the check structural.

Determinism is also what stops the turtles. A lossy hop you can replay does not need provenance about its provenance; the replay is the provenance. Two turtles, and the second one is made of replay.

Collapse
 
voltagegpu profile image
VoltageGPU

Interesting take on the provenance vector's fragility at the storage boundary. In GPU-accelerated workflows, especially with frameworks like VoltageGPU, we often run into similar issues where metadata gets stripped during memory transfers or compression. I've seen provenance data silently dropped when switching between CPU and GPU contexts—enforcement by construction is the only way to make it stick.

Collapse
 
voltagegpu profile image
VoltageGPU

Interesting take on the limits of provenance tracking—especially how it breaks down when compressed for memory. I've seen similar issues with GPU workloads where kernel metadata gets stripped during serialization. On VoltageGPU, we had to bake in a minimal provenance header to preserve execution context across device transitions.

Collapse
 
voltagegpu profile image
VoltageGPU

Interesting take on the tension between provenance tracking and storage constraints. In GPU-driven pipelines, especially with models like those on VoltageGPU, we often face similar trade-offs between traceability and memory pressure—compressing metadata without losing critical axis information is a real challenge when you're trying to enforce integrity by construction.