DEV Community

Cover image for Your Gate Trusts a Signal the Model Wrote. One Write-Hop Proves It.
Alexey Spinov
Alexey Spinov

Posted on • Originally published at finops.spinov.online

Your Gate Trusts a Signal the Model Wrote. One Write-Hop Proves It.

A write-chain taint lint answers one question before your AI-agent gate authorizes anything: did a model write any store behind each authorization signal? gate_taint_lint.py walks the declared write-closure and classifies every signal as WORLD_ANCHORED, MODEL_AUTHORED, or MODEL_LAUNDERED. In this post's fixtures, one write-hop flips sender_trust from PASS to FAIL, exit 0 to exit 1.

AI disclosure: I wrote gate_taint_lint.py with an AI assistant and ran it myself, offline, before publishing. Every number in the output blocks below is pasted from a real local run on Python 3.13.5, standard library only, no network. I checked the exit codes (0 / 1 / 2), hashed the STDOUT twice to confirm it is byte-for-byte deterministic, and edited every line. The external quotes (the Dev.to threads on model-authored signals, the arXiv paper on authorization propagation) are their words, not mine, and I link the primary sources. Their claims stay out of my metrics; my numbers come only from the runs shown here.

In short:

  • A signal's name tells you nothing about who wrote it. sender_trust reads as world-anchored. Only the write-chain can say whether a model filled the table it comes from.
  • The rule, borrowed from a live Dev.to thread and made checkable: a feature may hold the authorization role only if its transitive write-closure contains no model principal. Model-written signals can be context, never authorization.
  • Routing the model's output through a reputation table does not wash the taint. The lint computes the closure, so one intermediate store changes nothing. Neither would five.
  • The demo that matters: two manifests, byte-identical except one write-hop (who fills reputation_table: human-signed approvals, or model:classifier_v3). The verdict flips from exit 0 with 0 of 2 authorization features tainted to exit 1 with 1 of 2.
  • Standard library only (json, sys). Offline, keyless, read-only, zero network, deterministic STDOUT. The tool and all fixtures are in this post.

The trap has a name on it

Here is a gate I would have trusted six months ago. An inbox agent decides whether to auto-execute a request or hold it for review. The decision keys on three signals. sender_trust, a score read from a reputation table. tx_reversibility, read from the payment ledger. model_confidence, the classifier's own estimate of itself. The team knows better than to let the model vouch for the model, so model_confidence is demoted to context. The other two authorize. Everyone signs off.

Nobody asks who writes the reputation table.

That question is the whole post. On July 1, a Dev.to author writing as yongrean redesigned a comment-section trust model around one sorting rule: "Sort your features by whether their source is independent of the model. Gate on those. Treat the self-authored one as context, never authorization." Their words, not mine, and I think the rule is right. But as stated it is a read-time instruction, and the property it depends on is a write-time property. You cannot sort features by independence while looking at the feature. You have to look at the chain of writers behind it, all the way down, because the compromised link is rarely the store you read. It is some store two hops upstream that a model has been quietly filling for months.

Two days ago I published a gate that reconciles an agent's actions against policy, and the honest gap it left open was the other end of the pipe: that tool checks what comes out of a gate that compares trace against policy, verdicts on actions. Today's tool checks what goes in. A per-action gate with a model-written authorization feature is a gate the model holds a key to, no matter how good its policy is.

So the thesis, stated so you can break it: a gate feature may hold the authorization role only if its transitive write-closure contains no model principal. Given a declared write map, the taint class of every feature is computable, deterministically, before the gate authorizes a single action. Show me an authorization feature with a model in its declared write-closure that this lint marks WORLD_ANCHORED, or the reverse, and the tool is broken and the thesis with it.

What a write-chain manifest declares

The lint reads one JSON manifest with two parts.

stores is the write map: for every store the gate reads, who writes into it. A writer is either a principal with a kind prefix (human:sre_approver, external:bank_feed, model:classifier_v3) or the name of another store, which is how derived data declares its parents. gate_features lists the signals the gate keys on: each reads one store and holds one role, authorization or context.

Here is the clean fixture in full. It is the gate from the opening paragraph, wired the way its authors believe it is wired:

{
  "stores": {
    "approvals_log":     {"written_by": ["human:sre_approver"]},
    "payment_ledger":    {"written_by": ["external:bank_feed"]},
    "reputation_table":  {"written_by": ["approvals_log"]},
    "sender_trust":      {"written_by": ["reputation_table"]},
    "model_self_report": {"written_by": ["model:classifier_v3"]}
  },
  "gate_features": [
    {"name": "sender_trust",     "reads": "sender_trust",      "role": "authorization"},
    {"name": "tx_reversibility", "reads": "payment_ledger",    "role": "authorization"},
    {"name": "model_confidence", "reads": "model_self_report", "role": "context"}
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note what the manifest makes explicit that a config review usually leaves implicit: sender_trust is three writes away from a human. The score is computed from reputation_table, which is filled from approvals_log, which a human SRE signs. Declared like this, the chain is auditable. Undeclared, it is folklore.

Run it in sixty seconds

No keys. No network. No install beyond Python. Save the file, save a manifest, run one command. Here is the whole tool, one file, standard library only:

#!/usr/bin/env python3
"""
gate_taint_lint.py -- a write-chain taint lint for the signals an AI-agent
gate keys on, run BEFORE the gate authorizes anything.

Reads ONE manifest JSON with two parts:
  * stores        -- the declared write map: for every store the gate reads,
                     who writes into it. A writer is either a principal
                     ("human:<id>", "external:<id>", "model:<id>") or the
                     name of another declared store (derived data).
  * gate_features -- the signals the gate keys on: each reads one store and
                     holds one role, "authorization" or "context".

For every feature it computes the transitive write-closure of the store it
reads (every writer, every writer of every upstream store, down to the
principal leaves) and assigns a taint class:
  WORLD_ANCHORED  -- only human:* / external:* principals in the closure
  MODEL_AUTHORED  -- a model:* principal writes the read store directly
  MODEL_LAUNDERED -- a model:* principal is in the closure behind >=1
                     intermediate store (the reputation-table trick)
plus one flag:
  FEEDBACK_LOOP   -- the write graph reachable from the feature contains a
                     cycle that a model feeds (the signal helps author its
                     own history).

A model-tainted feature in role "context" is INFO. The same taint in role
"authorization" is a DENY: a signal the model can write cannot authorize
the model's actions.

Offline. Keyless. Read-only. Zero network. Standard library only (json, sys).
It does NOT inspect a real database, verify grants, run the agent, or detect
prompt injection. It lints the write map you declare. If the manifest lies
about the writers, the lint will not know.

Exit codes (usable as a CI gate):
  0  no authorization feature carries a model in its write-closure
  1  >=1 authorization feature is model-authored or model-laundered
  2  bad input (missing file, malformed JSON, undeclared store, unknown
     role, unknown principal kind, store with no declared writers)

Usage:
  python3 gate_taint_lint.py <manifest.json>
"""

import json
import sys

PRINCIPAL_KINDS = ("external", "human", "model")
ROLES = ("authorization", "context")


def _bad(msg):
    print("ERROR: " + msg)
    raise SystemExit(2)


def _is_principal(name):
    return ":" in name


def _kind(name):
    return name.split(":", 1)[0]


def load_manifest(path):
    try:
        with open(path, "r") as fh:
            raw = fh.read()
    except OSError as exc:
        _bad("cannot read manifest: %s" % exc)
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        _bad("manifest is not valid JSON: %s" % exc)
    if not isinstance(data, dict):
        _bad("manifest must be a JSON object")
    return data


def validate(data):
    stores = data.get("stores")
    if not isinstance(stores, dict) or not stores:
        _bad("manifest.stores must be a non-empty object")
    for name in sorted(stores):
        if _is_principal(name):
            _bad("store name '%s' may not contain ':' (reserved for principals)" % name)
        spec = stores[name]
        if not isinstance(spec, dict):
            _bad("stores[%s] must be an object" % name)
        writers = spec.get("written_by")
        if not isinstance(writers, list) or not writers:
            _bad("stores[%s].written_by must be a non-empty list "
                 "(a store with no declared writers is not world-anchored)" % name)
        for w in writers:
            if not isinstance(w, str):
                _bad("stores[%s].written_by entries must be strings" % name)
            if _is_principal(w):
                if _kind(w) not in PRINCIPAL_KINDS:
                    _bad("unknown principal kind in '%s' (stores[%s]); "
                         "known kinds: external, human, model" % (w, name))
            elif w not in stores:
                _bad("stores[%s] written by undeclared store '%s'" % (name, w))
    features = data.get("gate_features")
    if not isinstance(features, list) or not features:
        _bad("manifest.gate_features must be a non-empty list")
    for feat in features:
        if not isinstance(feat, dict):
            _bad("each gate feature must be an object")
        for key in ("name", "reads", "role"):
            if not isinstance(feat.get(key), str):
                _bad("each gate feature needs string fields: name, reads, role")
        if feat["role"] not in ROLES:
            _bad("feature '%s' has unknown role '%s' "
                 "(known roles: authorization, context)" % (feat["name"], feat["role"]))
        if feat["reads"] not in stores:
            _bad("feature '%s' reads undeclared store '%s'" % (feat["name"], feat["reads"]))
    return stores, features


def closure(store, stores):
    """Every node reachable from `store` along written_by edges."""
    principals, reached = set(), set()
    stack = sorted(stores[store]["written_by"])
    while stack:
        node = stack.pop()
        if _is_principal(node):
            principals.add(node)
        elif node not in reached:
            reached.add(node)
            stack.extend(sorted(stores[node]["written_by"]))
    return principals, reached


def model_chain(store, stores):
    """Deterministic shortest write path from `store` to a model principal."""
    seen = {store}
    queue = [[store]]
    while queue:
        path = queue.pop(0)
        for w in sorted(stores[path[-1]]["written_by"]):
            if _is_principal(w):
                if _kind(w) == "model":
                    return path + [w]
            elif w not in seen:
                seen.add(w)
                queue.append(path + [w])
    return None


def main(argv):
    if len(argv) != 2:
        print("usage: gate_taint_lint.py <manifest.json>")
        raise SystemExit(2)

    stores, features = validate(load_manifest(argv[1]))

    principals_of, reached_of = {}, {}
    for name in sorted(stores):
        principals_of[name], reached_of[name] = closure(name, stores)
    # a store is in a write cycle when it can reach itself along written_by
    looped = {n for n in stores if n in reached_of[n]}

    rows = []
    for feat in sorted(features, key=lambda f: f["name"]):
        read = feat["reads"]
        kinds = {_kind(p) for p in principals_of[read]}
        direct = any(_is_principal(w) and _kind(w) == "model"
                     for w in stores[read]["written_by"])
        if direct:
            klass = "MODEL_AUTHORED"
        elif "model" in kinds:
            klass = "MODEL_LAUNDERED"
        else:
            klass = "WORLD_ANCHORED"
        involved = {read} | reached_of[read]
        loop_fed = sorted(n for n in involved if n in looped
                          and any(_kind(p) == "model" for p in principals_of[n]))
        rows.append({"name": feat["name"], "role": feat["role"], "read": read,
                     "klass": klass, "loop": loop_fed,
                     "chain": model_chain(read, stores)})

    n_auth = sum(1 for f in features if f["role"] == "authorization")
    out = []
    out.append("GATE-TAINT-LINT REPORT")
    out.append("stores declared: %d" % len(stores))
    out.append("gate features: %d (authorization: %d, context: %d)"
               % (len(features), n_auth, len(features) - n_auth))
    out.append("write-chain classes:")
    tainted = []
    for r in rows:
        flag = " [FEEDBACK_LOOP]" if r["loop"] else ""
        out.append("  - %s [%s] reads %s -> %s%s"
                   % (r["name"], r["role"], r["read"], r["klass"], flag))
        if r["klass"] == "WORLD_ANCHORED":
            continue
        chain = "<-".join(r["chain"])
        if r["role"] == "context":
            out.append("      INFO: model in write-chain, held as context "
                       "(never keys authorization)")
            out.append("      chain=%s" % chain)
        else:
            out.append("      DENY: model in write-closure via %d intermediate store(s)"
                       % (len(r["chain"]) - 2))
            out.append("      chain=%s" % chain)
            tainted.append(r)
        if r["loop"]:
            out.append("      FEEDBACK_LOOP: model-fed cycle in write graph: %s"
                       % ", ".join(r["loop"]))
    out.append("authorization features tainted: %d of %d" % (len(tainted), n_auth))
    for r in tainted:
        out.append("  - %s: %s chain=%s" % (r["name"], r["klass"], "<-".join(r["chain"])))
    if tainted:
        out.append("VERDICT: FAIL: %d authorization feature(s) carry a model "
                   "in their write-closure" % len(tainted))
        out.append("  a signal the model can write cannot authorize the model's actions")
        code = 1
    else:
        out.append("VERDICT: PASS: no authorization feature carries a model "
                   "in its write-closure")
        code = 0

    print("\n".join(out))
    raise SystemExit(code)


if __name__ == "__main__":
    main(sys.argv)
Enter fullscreen mode Exit fullscreen mode

The baseline: every authorization signal is world-anchored

Run it on the clean manifest:

$ python3 gate_taint_lint.py fixtures/clean.json
GATE-TAINT-LINT REPORT
stores declared: 5
gate features: 3 (authorization: 2, context: 1)
write-chain classes:
  - model_confidence [context] reads model_self_report -> MODEL_AUTHORED
      INFO: model in write-chain, held as context (never keys authorization)
      chain=model_self_report<-model:classifier_v3
  - sender_trust [authorization] reads sender_trust -> WORLD_ANCHORED
  - tx_reversibility [authorization] reads payment_ledger -> WORLD_ANCHORED
authorization features tainted: 0 of 2
VERDICT: PASS: no authorization feature carries a model in its write-closure
Enter fullscreen mode Exit fullscreen mode

Exit 0. Two things worth a look. First, the lint is not allergic to models: model_confidence is MODEL_AUTHORED and that is fine, because its role is context. The INFO line is the sorting rule from the thread, executed instead of remembered. Second, sender_trust earns WORLD_ANCHORED by its chain, not by its name: the closure behind it bottoms out at human:sre_approver and nothing else.

One write-hop flips the verdict

Now the demo this post exists for. The second manifest is byte-identical to the first except for a single line. Not a new feature, not a new policy, not a renamed store. One write-hop:

$ diff fixtures/clean.json fixtures/laundered.json
5c5
<     "reputation_table":  {"written_by": ["approvals_log"]},
---
>     "reputation_table":  {"written_by": ["model:classifier_v3"]},
Enter fullscreen mode Exit fullscreen mode

In English: the reputation table is no longer filled from the human-signed approvals log. It is filled by the classifier. Perhaps someone shipped an "auto-approve high-confidence senders" change in Q1 and the approvals log became vestigial. The gate code did not change. The feature list did not change. The score in the sender_trust store is still a number between 0 and 1, indistinguishable at read time from the world-anchored version.

$ python3 gate_taint_lint.py fixtures/laundered.json
GATE-TAINT-LINT REPORT
stores declared: 5
gate features: 3 (authorization: 2, context: 1)
write-chain classes:
  - model_confidence [context] reads model_self_report -> MODEL_AUTHORED
      INFO: model in write-chain, held as context (never keys authorization)
      chain=model_self_report<-model:classifier_v3
  - sender_trust [authorization] reads sender_trust -> MODEL_LAUNDERED
      DENY: model in write-closure via 1 intermediate store(s)
      chain=sender_trust<-reputation_table<-model:classifier_v3
  - tx_reversibility [authorization] reads payment_ledger -> WORLD_ANCHORED
authorization features tainted: 1 of 2
  - sender_trust: MODEL_LAUNDERED chain=sender_trust<-reputation_table<-model:classifier_v3
VERDICT: FAIL: 1 authorization feature(s) carry a model in their write-closure
  a signal the model can write cannot authorize the model's actions
Enter fullscreen mode Exit fullscreen mode

Exit 1. 1 of 2 authorization features tainted, and the DENY line prints the receipt: chain=sender_trust<-reputation_table<-model:classifier_v3. Same gate, same features, same read paths. The only thing that moved is who holds the pen one table upstream, and that is enough to hand the model a key to its own gate. If your review process would have caught that Q1 change, good. Mine would not have, and I have reviewed changes exactly like it.

Why one intermediate store fools the naive check

The obvious implementation of yongrean's rule checks direct writers: for each authorization feature, look at who writes the store it reads. That check passes the laundered manifest. sender_trust is written by reputation_table, and a table is not a model, so a direct-writer check shrugs and moves on. The model never touches the feature's store. It writes one hop back, and the derivation launders the authorship.

A commenter on that same thread, dipankar_sarkar, named the fix: "A gate feature inherits the dirt of every writer upstream, even when the read path looks clean." A taint problem, they called it, and taint is exactly the right frame. The lint therefore computes the transitive write-closure: writers of the store, writers of those writers, down to the principal leaves, with a visited set so cycles terminate. MODEL_LAUNDERED is not a softer verdict than MODEL_AUTHORED. It is the same taint wearing a better suit, and in the wild I would expect it to be the more common shape, though I have no census to back that up. Nobody wires model_output straight into an authorization column. Wiring it into a "reputation" table that a scoring job reads feels like architecture.

I used the same taint intuition once before, pointed at a different object: the eval contamination probe walks file wiring to catch an agent writing what its own grader reads. That was the eval rig lying to you. This is the production gate, and the write-chain of every signal it authorizes on.

The limit case: the signal feeds its own history

There is a nastier shape than laundering, and a different commenter on the thread, anp2network, described it exactly: "If the sender's record improves because earlier messages were accepted by this same classifier, then the model is already in the provenance chain." That is not a chain anymore. That is a loop: the classifier's accept decisions fill the history, the history feeds the reputation, and the reputation authorizes the next accept.

The third fixture wires that loop. classifier_decisions is written by the model and by reputation_table (each decision records the reputation it keyed on), and reputation_table is rebuilt from classifier_decisions:

{
  "stores": {
    "approvals_log":        {"written_by": ["human:sre_approver"]},
    "payment_ledger":       {"written_by": ["external:bank_feed"]},
    "classifier_decisions": {"written_by": ["model:classifier_v3", "reputation_table"]},
    "reputation_table":     {"written_by": ["classifier_decisions"]},
    "sender_trust":         {"written_by": ["reputation_table"]},
    "model_self_report":    {"written_by": ["model:classifier_v3"]}
  },
  "gate_features": [
    {"name": "sender_trust",     "reads": "sender_trust",      "role": "authorization"},
    {"name": "tx_reversibility", "reads": "payment_ledger",    "role": "authorization"},
    {"name": "model_confidence", "reads": "model_self_report", "role": "context"}
  ]
}
Enter fullscreen mode Exit fullscreen mode
$ python3 gate_taint_lint.py fixtures/feedback.json
GATE-TAINT-LINT REPORT
stores declared: 6
gate features: 3 (authorization: 2, context: 1)
write-chain classes:
  - model_confidence [context] reads model_self_report -> MODEL_AUTHORED
      INFO: model in write-chain, held as context (never keys authorization)
      chain=model_self_report<-model:classifier_v3
  - sender_trust [authorization] reads sender_trust -> MODEL_LAUNDERED [FEEDBACK_LOOP]
      DENY: model in write-closure via 2 intermediate store(s)
      chain=sender_trust<-reputation_table<-classifier_decisions<-model:classifier_v3
      FEEDBACK_LOOP: model-fed cycle in write graph: classifier_decisions, reputation_table
  - tx_reversibility [authorization] reads payment_ledger -> WORLD_ANCHORED
authorization features tainted: 1 of 2
  - sender_trust: MODEL_LAUNDERED chain=sender_trust<-reputation_table<-classifier_decisions<-model:classifier_v3
VERDICT: FAIL: 1 authorization feature(s) carry a model in their write-closure
  a signal the model can write cannot authorize the model's actions
Enter fullscreen mode Exit fullscreen mode

Exit 1 again, but with the extra flag: the write graph contains a cycle (classifier_decisions and reputation_table write each other, directly or through the chain) and a model feeds it. A loop like this does not just taint the signal. It compounds: every accept the model buys makes the next accept cheaper. If your agent lives on-chain, read sender_trust as senderTrust or whatever reputation score your agent economy keeps; a memory that improves because the model approved the earlier writes is this same graph with different table names.

How does the write-chain taint lint classify each signal?

Small enough to hold in your head. For each store, compute the closure along written_by edges with an iterative DFS and a visited set, collecting principal leaves. A feature's class is then three lines of logic: a model:* among the read store's direct writers is MODEL_AUTHORED; a model anywhere deeper in the closure is MODEL_LAUNDERED; otherwise WORLD_ANCHORED. The printed chain is the shortest write path from the read store to a model principal, found by BFS with sorted expansion so the output never wobbles between runs. FEEDBACK_LOOP sets when some store reachable from the feature can reach itself and a model is in that store's closure.

Two design choices I expect pushback on, so let me defend them now. First, a store with an empty written_by list is exit 2, not WORLD_ANCHORED. While writing the validator I sat on this one for a while, because the tempting read is "nobody writes here, so it is safe." Backwards. An empty writer list means undeclared provenance, and undeclared provenance failing open is how the laundered manifest happens in real life. Second, an unknown principal kind is also exit 2. Typo modle:classifier_v3 and a lenient lint would wave your classifier through as world-anchored. Fail-closed means a typo costs you a build, not a trust model.

The taint model is binary on purpose, and that is a real limitation, not modesty theater. human:* should mean a human authorizes each write. The common hybrid, a human batch-approving the model's suggested reputation updates every Friday, is genuinely neither kind. My position: declare the principal who authorizes the write, and if that Friday review is a rubber stamp, declaring it human:* is a lie the lint cannot catch. It lints the map, not the territory.

The field is converging on write-provenance

I am not arguing from one thread. Krti Tallam's paper on authorization propagation in multi-agent AI systems puts the boundary in one sentence about the problem: "It is not reducible to prompt injection and is not fully addressed by classical access-control models such as RBAC, ABAC, or ReBAC." Their conclusion, not mine, and it matches what the write-chain shows: you can solve injection and still authorize on a signal your model wrote.

Adversa AI's June 2026 roundup of agentic security resources lands the same way: treat every input the agent ingests as potentially hostile, every action as potentially dangerous, and close the gap with real boundaries such as least-privilege scopes, sandboxed execution, and human review where the blast radius is large. Their framing, not mine, and a gate feature is exactly an input the gate ingests.

And the corroboration angle keeps surfacing in the comments of this wave. On yongrean's earlier post about confidence, commenter jugeni put it in nine words: "AUTO wants a corroborator the model cannot write, not a confidence it can." On Ishaan Sehgal's The Log Is the Agent, commenter nexus-lab-zen drew the trust boundary I keep coming back to: "the verdict on the run has to live in a different trust domain than the one that wrote the log." Same instinct, four different authors, one property underneath: what the model can write, the model can bend.

Where this sits next to the rest

This is a spoke on the pre-execution gate for AI agents cluster, and it is the first one pointed at the gate itself: input hygiene for the thing that does the blocking. The neighbors, and how this differs:

A gate that keys on model-authored signals is tracking wearing a control costume: the model authorizes itself through one layer of indirection, and the gate's job title does not change that.

What this is NOT

I would rather undersell this than have you deploy it as something it is not.

  • It lints the declared write map. If the manifest says approvals_log is human-signed and in reality a cron job lets the model append to it, the lint reports a clean chain. Garbage in, garbage out. The honest wedge is the same as an SBOM: reading the declared wiring is worth doing precisely because most stacks have never written the wiring down.
  • It is not runtime enforcement. Nothing is intercepted, no write is blocked. It is a pre-deploy CI check on a JSON file.
  • It is not a prompt-injection detector. A perfectly benign model with no attacker in sight still fails the lint if it writes an authorization feature, because the problem is structural, not adversarial.
  • It is not a lineage tracker. No column-level flows, no time dimension, no sampling of actual rows. Real lineage systems reconstruct what happened; this classifies what you declared.
  • It does not prove the absence of undeclared write paths. A green run means "the map you drew has no model behind authorization," never "no model can reach your tables."
  • It anchors on declared principals, not on their presence. A closed loop of stores that only write each other, with no principal anywhere in the closure, passes as WORLD_ANCHORED, vacuously: no model in the chain, but nothing anchoring it to the world either. The empty-writers check catches an undeclared leaf, not a principal-free cycle. Both independent reviews of this tool flagged it, and they are right. If your write map has islands like that, declare the principal that seeds them, or treat the green as unearned.
  • The numbers here are fixture units, not a prod measurement. The 1 of 2 describes this post's synthetic manifests. Run it on your own write map to get a number that means something about your stack.

Bad input fails closed

A lint that crashes open is worse than no lint. Point it at a manifest where a feature reads a store nobody declared:

$ python3 gate_taint_lint.py fixtures/bad.json
ERROR: feature 'sender_trust' reads undeclared store 'sender_trust'
$ echo $?
2
Enter fullscreen mode Exit fullscreen mode

No arguments, unreadable file, malformed JSON, unknown role, unknown principal kind, a store with no declared writers: all exit 2, distinct from exit 1 so your CI can tell "tainted" apart from "could not read the map." I ran each fixture twice and hashed the full STDOUT both times: clean is bb8d9b35..., laundered is bec4a071..., feedback is 68065873..., identical across runs. Byte-for-byte, on Python 3.13.5, offline.

The question I actually want answered

What is the closest thing to a write-chain manifest your stack already has? A dbt lineage graph, a CDC topic map, the IAM write grants on your trust tables, a tribal diagram in someone's head? I genuinely do not know what the median answer looks like, and I suspect for most agent stacks it is the diagram in the head. If you can export even a rough stores map, run this lint against your gate's features and tell me which class your sender_trust lands in. I expect more MODEL_LAUNDERED than anyone will admit, and I would be happy to be wrong.

If this was useful, follow along here for the next runnable gate in the series, and drop the ugliest write-chain you have ever found behind a "trust" score in the comments. I read every comment.

Top comments (0)