Skip to content

About

Cross-repo gates for the TAIPANBOX agent-governance estate: the checks no single repository can run on itself.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

estate-gates

The only repository in the TAIPANBOX estate allowed to know about more than one repository at once.

Why it exists

Sixteen repositories, each with unusually good gate discipline. Each names its invariants. Each has scripts that fail a push when a claim stops being true. Every one of them green.

A day of auditing found that almost every real defect was between them, where no gate could see:

  • verdryx held seven of tokenfuse's nine Breaker block-decision wire strings, so for eleven days it counted avoided estimates as real money;
  • agent-stack-go's vendored copy of the passport schema fell three weeks behind the canonical one, so its "full schema validation" silently skipped the two fields that exist for AI Act code inventory;
  • agent-passport's registry said idryx emits events; idryx has no event writer at all, and three artifacts carried that claim for weeks after the prose was corrected;
  • idryx pinned agent-stack-go v0.3.0 while the module was at v0.5.1, and the entire delta was the tamper-evidence chain verifier idryx most needed;
  • the same stack is deployed three ways and they disagree on which governance routines run, on a severity default, and on which components come up.

None of those is a bug inside a repository. Every one is a disagreement between two of them, and a single-repo gate cannot have an opinion about a file it is not allowed to read. This repository is the answer to that class, and it is the only place with that permission.

The two rules

1. A check that cannot fail is worse than no check, because it reports green forever and somebody trusts it. Every check here is proven capable of failing by ./selftest.py, which builds a miniature estate, breaks one thing at a time, and requires the matching finding to fire. It also reads the gates' own AST, so a FAIL path added tomorrow without a mutation fails the self-test rather than joining the suite unexamined.

2. A check whose subject has vanished FAILS LOUDLY. A file that is gone, a repository that is missing, an anchor that matches nothing: all three are red, with a message naming what disappeared. Silent success on a missing subject is how the estate got here. The one exception is a repository estate.json records as having no public remote, and even then the run reports PARTIAL and names it, never clean.

The checks

C1, pin currency (gates/c1-pin-currency.py). For every repository whose go.mod requires github.com/TAIPANBOX/agent-stack-go, compares the pin with the module's newest tag. Any lag fails; the finding says whether it is a minor (a different contract, since the module is pre-1.0) or a patch (the same contract, missing fixes). A pin ahead of every tag fails too, as does a replace directive, which makes the pin unenforceable. A fresh release turns this red for every consumer on the day it is cut, deliberately: that is the day the estate is most drifted, and a grace period would report green during the exact window the check exists to describe.

C2, vendored schema equality (gates/c2-vendored-schemas.py). The eight vendored copies of agent-passport's three canonical schemas, across agent-stack-go, genaryx, engram and verdryx, compared BYTE for byte with a unified diff of the first difference. Byte-exact on purpose: two JSON documents that differ only in whitespace or key order are the same schema to a validator and a different one to the person who opens the file to see what the canonical says.

C3, mirrored constants (gates/c3-mirrored-constants.py). verdryx's three hand-copied mirrors of tokenfuse constants: the Breaker block-decision wire strings (compared as sets), the default price book (per model and on the fallback), and the trace Parquet column names verdryx reads (containment: it reads five of sixteen). It PREFERS tokenfuse's published contracts/tokenfuse-constants.json when that artifact is present and parses the Rust when it is not, and it says which mode it used in every run, because a comparison whose reader does not know what was compared is one somebody will quote wrongly later.

C3's fourth mirror is the tool-call argument cap: tokenfuse's TOOL_CALL_ARGUMENTS_CAP_BYTES against wardryx's MaxToolArgumentsBytes, read from source in both, because tokenfuse's artifact does not carry it. The two must be equal. Tokenfuse cutting arguments above a bound wardryx refuses, or below one, decides calls between the two without a verdict or without their arguments. A value the check cannot evaluate is refused by its text (c3.argcap-unreadable), never guessed at.

C4, event registry versus actual producers (gates/c4-event-registry.py). agent-passport SPEC.md section 6.2 is the registry: source to event types. Both directions. Forward: every source it lists has a real event-writing code path and every type string it lists appears at an emit site. Reverse, and more valuable: any type string emitted by any repository whose source the registry does not carry, or which is attributed to the wrong source. Anchored on the emit call sites in Rust, Go and Python, never on strings that look like event names, and a repository whose shape defeats the parser is reported as a hole rather than skipped. A producer that is a command inside a shared module rather than a repository of its own (agent-conform in agent-stack-go) is declared under the module's entry and read from its const block, and the extractor requires each declared name to still be written. The search for an undeclared writer asks only whether a repository is declared, so a SECOND writer added inside a declared repository is not found by it.

Every producer reads the non-test source files of its code directories, not the one file that first held its emits. A Go producer resolves its ev* and type constants per package and reads either the event literals or the emit helper's first argument from every file (wardryx's identity-mismatch emit sits in agent_token.go, and vouchryx's delegation emits in agents.go and xaa.go). Python reads every module of the package, and genaryx every crate file that inserts a literal console type. A type that cannot be read is refused, and an empty read says it measured nothing. tokenfuse (one closed enum), costcrew (one declared list), taipan (a replayer of one table) and genaryx's demo and mock generators (computed types) are the named exceptions, recorded in invariant 30 of CLAUDE.md as partly gated.

C5, deployment parity (gates/c5-deployment-parity.py). stack-up, stack-single and stack-k8s compared on four things: which governance routines each installs, the HERALDYX_MIN_SEVERITY default, the fixed local port map, and which components each brings up. Differences are not automatically failures, because several are deliberate, so this one fails on any divergence that expectations/deployment-parity.json does not record with a reason and a date. It fails the other way too: an expectation recorded for a divergence that no longer exists is red, so the file cannot become a graveyard of stale allowances.

The stack-single no-scheduler check reads executable scheduling forms in its installer and Compose file. A systemd service drop-in that only orders Docker startup is not a scheduled governance routine. Scheduling forms outside the check's named patterns remain a limit of this source reader.

C6, cross-language chain vectors (gates/c6-chain-vectors.py). agent-stack-go/event/testdata/chain-vectors.json pins the RFC 8785 canonicalization and the SPEC 6.5 chain hashes. Three implementations retype those values as literals in their own suites: Rust in tokenfuse, Python in engram and in verdryx. A change to the Go canonicalization would not fail any of them today. This compares every copy with the file.

C7, the agent_id rule copied into code (gates/c7-rule-in-code.py). C2 holds vendored schema FILES byte-identical and cannot see a rule that was read out of a schema once and retyped as a constant. The estate has four of those for one rule, SPEC 3.1's agent://<trust-domain>/<name> grammar and its 255-byte cap: agent-stack-go/passport/passport.go, which six repositories import by tag, the Python copies in engram and verdryx, and the Rust one in tokenfuse. A drifted file shows up in a diff; a drifted constant compiles, passes its own suite, and is visible only to somebody holding both repositories open. Each extractor anchors on a NAME and captures whatever pattern sits beside it, so a rename breaks loudly rather than quietly finding nothing. Three anchor on a constant; tokenfuse compiles its regex inside a function, so that one anchors on the function and refuses to cross another fn.

C8, the registry reaches the record (gates/c8-registry-reaches-the-record.py). C4 holds SPEC 6.2 against the producers and says nothing about what becomes of an event afterwards. trailryx is the record plane, and a type its ingest door does not know is refused as UnknownType and counted. That refusal is correct behaviour, and it is also what an omission looks like: "we decided this does not belong in the record" and "nobody got to it" produce the same refusal, the same counter and the same silence. trailryx already writes its decisions down, in a doc-comment passage naming the types it refuses on purpose, so this check asks only that every registered type appears on one of the two lists, the mapping arms or the refused names. It never requires a mapping: the record vocabulary is deliberately narrower than the bus, and forcing a record type per registered type would be the wrong direction. Writing it found policy_updated, wardryx's admin type at severity high, on neither list, which meant the record plane was silently dropping the event that says an operator changed the policy rules.

C9, a git command aimed elsewhere clears the hook's environment (gates/c9-foreign-git-in-hooks.py). git runs a hook with GIT_DIR set to the repository being pushed, and git -C <somewhere else> changes the working directory without clearing it, so the command reads the other repository's working tree against this one's index and object database. Found in trailryx on 2026-08-26: a check asking whether the local advisory database had untracked files answered nothing from a terminal and all 1221 of its entries from the pre-push hook, and refused the push. Three sessions retried instead of looking at the environment, because the failure only exists in a context nobody debugs from. The quieter shape is worse than the one that was found: show <ref>:<path> under the wrong object database resolves the ref in the wrong repository, and where both hold a file at that path it succeeds and returns the wrong content, so a check comparing a vendored copy against its original compares the copy against itself. The single rule that keeps this honest is that the target must sit before the subcommand, where git requires it, which is what keeps git archive HEAD | tar -x -C "$dir" out of the answer: five repositories write that line, and a check matching git and -C anywhere would have reported all five and been deleted by whoever read the first finding.

C10, the RFC 8693 mapping produces one chain in every language (gates/c10-delegation-mapping.py). agent-passport SPEC 5.3 says how an RFC 8693 act claim becomes an on_behalf_of chain, and it is the one place in this estate where a mistake produces something that VERIFIES PERFECTLY and asserts the opposite of what happened: a signature is over the claims and says nothing about how a reader turned them into a list. Two mistakes are available and both were made on 2026-08-26, in the hour the mapping was first written. The direction: the RFC nests act current-first, SPEC 5 orders the chain root-first, and reversing it wrongly records that the root delegated to nobody. The head: the RFC keeps the subject OUT of act and SPEC 5 puts the root INTO the chain, so the mapping is [sub] + reverse(act), and missing that writes the chain with the human missing from it. Two implementations exist and neither can see the other, each holding its expected chain as a literal in its own suite, so changing one leaves the other passing against the old answer. The gate reads the ASSERTION rather than the test body, which is the correction its own self-test forced: taking the last three principals found the same three whatever the assertion said, because a body also names the principals that BUILD the token, and the gate could not see the exact failure it exists for.

C12, every envelope member has a plane at the record (gates/c12-envelope-members-have-a-plane.py). C8 asks whether each event TYPE has an answer at the record plane. This asks it about the envelope's MEMBERS, and the failure is worse, because a member has no refusal path. trailryx's mapper files every member of a line in exactly one of two planes: a fixed list (CONSUMED) goes to typed metadata, which is kept, and everything else goes to the payload plane, which a per-event key ERASES. A member the mapper has never seen is therefore not refused and not counted. It is silently filed in the erasable half, and the store reports nothing.

What it found when it was written: delegation_proof (agent-passport SPEC 5.2), emitted by tokenfuse since 2026-08-26, which records that the on_behalf_of chain was proved by an RFC 8693 token. CONSUMED did not name it, so the chain sat in the kept plane and its proof in the erasable one. SPEC 5.2 reads a chain with no proof beside it as not proven, so a routine payload erasure would have turned a proven chain into an unproven one, silently, in the store whose claim is that it holds what happened in a form nobody can quietly alter.

The check is about coverage, never about which answer. Payload is a legitimate answer for most members, and for data the only correct one. A member passes when it is consumed into typed metadata, or when the mapper's plane-boundary passage names it in backticks and argues why it belongs in the erasable half. The subjects come from the schema files, never from a list in the script: every version of the event envelope schema in agent-stack-go (cmd/agent-conform/schemas) is read and its properties unioned, so a member added in a newer version is a subject the day it lands. The finding is c12.member:<name>. A run that finds no schema with properties (c12.schemas), or cannot find the mapper's CONSUMED list or its plane-boundary passage (c12.mapper-unreadable), says it measured nothing and fails; that is not a pass.

C13, the delegation depth cap counts one thing (gates/c13-delegation-cap.py). SPEC 5.1 reads "Maximum chain depth is 32 entries" and SPEC 5 calls the members of on_behalf_of entries, so the bound belongs to the assembled chain. SPEC 5.3's mapping is [sub] + reverse(act), so a producer building that chain out of an RFC 8693 token bounds two quantities one apart, and one sentence cannot tell it which one it meant.

Measured 2026-08-27 with agent-conform against a real emitted line: both producers in the estate had bounded the ACTORS at 32 and then prepended the subject, while every consumer bounded the CHAIN at 32. A token carrying 32 actors verified at the door and every record it produced was quarantined with maxItems: got 33, want 32. Every number read 32, every repository was internally consistent, and every suite was green: the disagreement was in the UNIT, which is precisely what no repository can see about another.

Three sides, all discovered. The SPEC's sentence, parsed for the number and for the unit word, so a reworded sentence is a red rather than a silent re-reading. Every JSON Schema anywhere in the estate that DECLARES on_behalf_of, found by searching each repository for the member, so a vendored copy is a subject the day it lands and one that bounds nothing is a consumer accepting what the SPEC forbids. And every cap constant under a chain or delegation path, where an entries cap must equal the SPEC's number and an actors cap must equal it minus one AND be derived from the entries cap rather than retyped. Rust caps re-exported from a sibling crate are resolved through the declared Cargo dependency and the exported constant; an unreadable alias is a finding.

The finding to read first is none of those three. A file that maps an act claim into a chain must state BOTH numbers, and that check does not depend on finding a cap at all, so a constant renamed out of the anchor cannot switch it off. Run against the estate as it stood on the morning of 2026-08-27 it names agent-stack-go/delegation/chain.go and tokenfuse/crates/delegation/src/lib.rs, which is the defect, from the only place in the estate that could have seen it.

C14, a vendored table is the table (gates/c14-vendored-tables-are-the-table.py). Some rules cannot be shared. agent-stack-go/chain holds what the record accepts of a delegation chain, agent-stack-go/delegation is a door that deps-layering.sh forbids from importing it, and tokenfuse/crates/delegation is a third implementation in another language with no seam to either, so the rules exist three times by construction. Three of them were found disagreeing on 2026-08-27, in one afternoon. Prose did not hold them, and a gate reading source text could not: a regex over two languages says a rule is mentioned, never that it answers. The answer was a TABLE each implementation runs, and a table only holds while every copy of it is the same table.

The subjects are found by $source, which every canonical table carries and which names its own path. A copy is any file carrying the same $source at a different path, so a new language vendoring the table is checked from the day it lands rather than the day somebody remembers this file. C6 does the same job for the hash vectors through a hand-written list of copies; this is that check with the list taken out. Three findings: c14.copy-drifted:<source> when a copy differs from the canonical by a byte, c14.canonical-missing:<source> when copies name a path nothing is at, and c14.copy-unexercised:<repo> when no file with a test declaration in its own language (#[test], func Test, def test_ and the like) names the copy, because byte-identical copies of a table nobody runs are files that agree about nothing. A reference that merely starts with the file name, such as a renamed .disabled copy, does not count. That last check is evidence that a suite reaches the file, and deliberately not a claim that the suite asserts on every vector: nothing a read-only gate can do reaches that far, and the distance is stated rather than implied away. A run that finds no table at all (c14.no-tables) measured nothing and fails, and a repository it could not read is named (c14.unreadable:<repo>), never skipped in silence.

C15, a declaration a repository proves, read across the estate (gates/c15-component-manifests.py, features/component-manifests.feature). The runs field in estate.json says which components exist, and nothing reads a repository to confirm it. That gap cannot be closed from here: the only thing that knows which binaries a repository builds is the repository, so a component that was FORGOTTEN is invisible from outside by construction, and runs: [] is a valid answer no central file can contradict. vouchryx was installable by nothing for nineteen hours on 2026-08-26 for exactly that reason. Nor can the checks that matter most be made from here: what separates a service that exits 2 without three variables from one that starts happily with an empty environment is invisible to every source-reading check and obvious to one that STARTS the binary, and this repository builds nothing. So the division is that the repository declares and proves, and this one reads across.

Each component repository may carry components.json at its root. Everything under a component's checked key is asserted by that repository's own suite, in its own CI. Everything under declared is a statement nobody can verify and must carry its own why. This check owns four things, deliberately modest:

  1. The manifests agree with estate.json: every component the registry says a repository contributes must be one its own manifest declares (c15.registry-names-what-the-repo-does-not), and a manifest naming a schema this reader does not know (c15.unknown-schema), or declaring nothing (c15.manifest-declares-nothing), is refused rather than misread.
  2. A declared entry with no why is refused (c15.declared-without-a-reason), so a repository that drops its own test does not quietly drop the rule.
  3. The comparison a single repository could never make: the health path a component declares against the path a deployment actually polls (c15.probe-disagrees). A launcher that deliberately probes something else says why under the component's declared bucket, and the check then reads a decision instead of a disagreement.
  4. A service or daemon that no launcher installs (c15.nothing-installs-it), the question the whole per-repository declaration was for. It was asked by hand on 2026-08-28 and found tokenfuse-cluster, a raft-replicated budget ledger installable by nothing for fifty-seven days. dev-tool is the class for something nothing should install, and tool is not judged. A declared entry naming the install decision is the answer.

It does not read source, so it cannot tell a manifest that is complete from one that is missing a component. Adoption is incremental and a repository without a manifest is a count, not drift; finding none at all is a failure (c15.no-manifest-anywhere), as are an unreadable manifest, an anchor that matches nothing (c15.probe-unreadable, c15.probe-anchor-matched-nothing) and the absence of any launcher manifest or any service to judge (c15.no-launcher-manifest, c15.launchers-install-nothing, c15.no-service-to-judge), because each of those would otherwise report agreement about nothing.

C16, a launcher's environment reaches a reader (gates/c16-launcher-env-has-a-reader.py). C5 compares the three launchers against each other, so it has no opinion about whether the thing all three agree on is wired to anything. This asks the other question, between a launcher and a binary: every environment variable a launcher hands to a process must be one some repository declares reading.

Two live instances the day it was written, both the same shape. stack-single/compose.yaml passed WARDRYX_DSN; wardryx reads WARDRYX_DB and has never read the other name, so the launcher generated a correct DSN, declared depends_on: policy-db with condition: service_healthy, waited for that database to come up, and handed the value over under a name the process ignores. The database was provisioned, waited on and never used, which left policy and approvals in memory: a restart dropped every console-written policy and unfroze the fleet while the console still showed it stopped. And stack-k8s sets TOKENFUSE_CLOUD_EVENTS_PATH into a container by configMapKeyRef; that name appears nowhere in tokenfuse, whose cloud reads TOKENFUSE_CLOUD_REPLAY_EVENTS, which no launcher sets at all.

A key with no reader is the quiet kind of wrong. Nothing is misspelled, nothing errors, the value is correct, the dependency is healthy and the service starts and answers. It is a wire that was never connected while every signal around it says the opposite. Neither instance is visible from inside a repository: the launcher's gates cannot read the binary and the binary's gates cannot read the launcher.

Both sides come from the repositories. The answer is every name under an env block in any components.json, and those declarations are not this suite's word for it: each declaring repository proves its own manifest against its own source in its own CI, per C15's argument about why that division is structural. The subjects are what the launchers DELIVER, by four forms read off the launchers themselves, with comments stripped first so that prose about a variable, including the comment recording the WARDRYX_DSN fix, is not a delivery. install.sh holds shell variables like WARDRYX_ADMIN_SECRET that reach no process, and flagging those would bury the real finding.

The subject is delivery to ONE service: a container's own env: entry, a compose service's own environment: mapping, a shell command prefix. A shared ConfigMap's keys are not subjects. The first version counted them and reported seven findings, three of them wrong for the same reason. The clearest was TOKENFUSE_CLOUD_EVENTS_PATH, which is a KEY whose value reaches the container under a different NAME (TOKENFUSE_EVENTS_PATH, which tokenfuse declares and reads); IDRYX_URL is the same shape, and stack-k8s interpolates TRAILRYX_TRUST_DOMAIN into an argument in a CronJob it writes itself, which trailryx's own suite deliberately keeps out of its manifest and says so. Three repositories were right and the check was wrong. Narrowing cost coverage on purpose: three false findings buy a check somebody still reads at the tenth run. That is also the shape the original defect had, since WARDRYX_DSN was in the wardryx service's own environment: block.

Its remaining limit: a variable delivered by a form not listed is invisible here and nothing would say so. The mitigation is that the forms are read from the launchers rather than imagined.

C17, a tag-only workflow is exercised by a pull request first (gates/c17-tag-workflow-pr-guard.py). A workflow gated on push: tags: v* runs for the first time, on every commit that ever reached it, on the day somebody cuts a release. tokenfuse's binaries job failed on both Linux runners the first time v0.4.2 exercised its musl leg, on a step that had been wrong since it was written and had simply never run before that tag. The fix, TAIPANBOX/tokenfuse#251, is two things done together: a pull_request trigger scoped to the workflow's own path, so an edit to the file is what exercises it, and every job that publishes guarded off that event, so the same pull request does not also push an image or cut a release. Subjects are found, never listed: every file under .github/workflows/ in every repository estate.json names whose on.push.tags matches v*. A survey by hand found ten repositories with the shape and no escape hatch at all; this gate's own discovery finds twelve, naming engram and terraform-provider-taipan as well, both of which publish (to PyPI and to the Terraform Registry) on a bare tag push with no pull_request trigger anywhere. Four equivalent guard expressions are accepted (github.event_name != 'pull_request', startsWith(github.ref, 'refs/tags/'), github.event_name == 'push', github.ref_type == 'tag'), because the estate's own clean files use more than one of them. What counts as publishing is a fixed, named list: docker/build-push-action with push or push-by-digest true, docker buildx imagetools create, softprops/action-gh-release, actions/upload-release-asset, gh release, docker push, cosign, crane, oras. A marker outside that list, or a guard that is logically equivalent but spelled differently, is invisible to it, which is stated here rather than silently missed.

C18, every architecture file is current against its repository (gates/c18-architecture-is-current.py). The architecture record, TAIPANBOX/architecture, holds one file per service saying how it is built and why, with the commit of that service's main it was last checked against in its frontmatter. This gate reads the record and every service repository and asks: does the file exist, or is the gap recorded with a date; is verified_at a commit on main; did code land on main after it; does every gate a gate:/gates:/partly gated: marker in ## 8. Invariants and gates names actually exist in that repository, however the marker is decorated (an asterisk parenthetical, a plain parenthetical, a table cell, or any of those wrapped onto a second line with the script on an indented continuation; the real estate uses all four). What it does not read is named, not silent: over the 22 registered repositories it visits, agent-passport.md names the script BEFORE the marker and cites a path outside scripts/, stack-single.md names two gates by path with no gate: word and one by bare file name, and taipan.md puts a comma between the word and its qualifier; bringing those rows to the same form as the rest of the estate is the next change, not this gate. A pending entry whose file exists is red, so the pending list cannot become a graveyard, and a dossier with no such section at all is red too, reported as measured nothing rather than passed on an empty read. The record is private by decision, so in CI this gate reports NOT MEASURED and is measured in local runs, the same way taipan was before it went public.

C19, a repository at 1.0 declares its compatibility surface (gates/c19-compat-manifests.py). SemVer's item 5: version 1.0.0 defines the public API, so a 1.0 tag is a promise about a surface, and a promise nobody can point at is a mood. The estate's first two 1.0 tags, agent-passport and agent-stack-go on 2026-09-12, each came with the surface written down (SPEC.md section 10; api/surface.txt) and a gate in the repository that fails when it moves. estate.json carries the declaration per repository: major, and compat naming the manifest and the gate (defaults compat/1.0.json and scripts/compat-surface.sh), or an exemption with its reason, printed on every run. For a declared major the manifest and the gate must exist and some workflow under .github/workflows must name the gate; the newest tag's major must not be below the declaration. A newest tag at 1.0.0 or above with no declaration is red, which is the failure the gate exists for. A repository at 0.x with no declaration has nothing to hold. It checks that the manifest and the gate exist and are wired, not that the manifest is true: that is the repository's own gate's job.

C20, every README install line resolves to a release that exists (gates/c20-install-lines-resolve.py). tokenfuse's front page said docker run ... ghcr.io/taipanbox/tokenfuse for two months while latest in the registry was a July build: the release workflow wrote latest only on the default branch, which a tag push never is. Route A of decision 10.26: no moving tag anywhere, a README pins the tag it means, and this gate holds the pin. It reads README.md of every repository estate.json names for ghcr.io/taipanbox/ image references and github.com/TAIPANBOX/.../releases/ download links, skipping lines with placeholders. An image with no tag is red unless the owning repository declares a moving tag; a pinned tag must be a git tag of the owner (the repository whose name is the longest prefix of the image name, or the one whose estate.json entry lists the image under images, the exception for an image not named after its repository such as agent-stack-go's agent-conform; a -variant suffix is stripped first); a releases/download/<tag>/ link needs a Release object for that tag and releases/latest/download/ needs at least one, both read through the GitHub API, or from a fixture the self-test exports. It does not ask the registry whether the image tag was pushed (outside-view.py does) and does not check asset names on a Release.

C21, every action a workflow uses is pinned to a commit (gates/c21-action-pins-are-commits.py). On 2026-09-12 twenty-one repositories pinned ossf/scorecard-action to the SHA of the annotated TAG OBJECT of v2.4.4 rather than the commit it points to, which git rev-parse and the GitHub API both hand back for an annotated tag. The workflow ran, and api.scorecard.dev refused every upload as an "imposter commit", so no repository had a published score when SUP-4 of the 1.0 proving run went to read one. The same files pinned github/codeql-action the same way. This gate reads every uses: line of every workflow, refuses a tag name (@v4) and a tag-object SHA (naming the peeled commit to pin instead), passes a commit at a ref tip, and reports a SHA at no tip as read but not judged. Ref tips come from git ls-remote --tags --heads once per action, or from a fixture the self-test exports.

C22, an optional add-on stays off the path every agent request takes (gates/c22-addons-stay-off-the-request-path.py). Decided on 2026-09-26: an optional add-on, typryx first, is integrated beside the core and never inside it, so a stack without it runs as before and a slow or failed add-on cannot slow or fail a request. The add-on talks to the rest of the stack through the shared event bus, and any direct link a consumer adds is optional and fails open. This gate holds the part only a cross-repository reader can see: no repository marked "request_path": true in estate.json (tokenfuse, wardryx) names a repository marked "addon": true, or any component its runs field lists, in any tracked file and in any case. Launchers are where an add-on is wired, by configuration, and are not read. It does not catch a dependency that never spells the name, such as a hard-coded address or port.

C23, every built-in model price table matches the vendors' current prices (gates/c23-model-prices-match-the-vendors.py, readers in gates/_prices.py). On 2026-10-13 the gateway's book had no row for Claude Haiku 5.5, a current model at 0.10 / 0.50 per million tokens, so the gateway billed it at the 15 / 75 fallback; its Sonnet 5.5 cache read was 0.20 where the page said 0.10; it had none of OpenAI's latest models; it priced Gemini 3.x Flash at the 2027 rate while the introductory rate was in force; and costcrew carried Sonnet 5 and Opus 5 at their old prices. A price is a claim about somebody else's list, and nothing compared the copy with the list. This gate does, in the existing daily run. Subjects come from estate.json: each repository declaring a price_table (path, format, and whether it must price every current model). It reads, live and with no key, Anthropic's pricing page (the model table by its column headers, and the sentence stating the regional premium, as "10%" or "10 percent" and in no other words), OpenAI's (every text-token table marked tier standard, read together: each price from the column its heading names, proved against the rows the table renders; a model two tables price identically is one row, and one they price differently is read from neither and reported), and Vertex AI's (the Standard tabs under "Google models", Global and Non-global rows, dated rows). A row billed through OpenRouter (costcrew's openrouter/... engine) must equal, exactly, the price of one of the providers OpenRouter's /models/{id}/endpoints lists for that model, every component from the same provider, and is red when no provider is listed; never OpenRouter's model list, whose single price switches provider. For the other rows OpenRouter is a secondary signal, the model's own vendor endpoint row; where it disagrees with a vendor page, the page wins and the disagreement is printed. A row is compared with the price in effect today: an id naming the global endpoint with the list, one naming a regional endpoint with the list plus the stated premium, and one naming no endpoint (or a model priced in prompt-length tiers) must equal one of the prices, every component from the same one. Only components both sides state are compared, within half a percent. Prompt-length tiers are read from both sides (tokenfuse's price_book.long_context, verdryx's .with_long_context, the vendors' "over 100,000 / 200K / 272K tokens" prices): a table that can carry a tier must carry the vendor's threshold and long price and no tier the vendor does not have; a flat table may carry either tier. A dated change within 30 days is a WARN line on every run, and red the day it takes effect. A disagreement a repository chose on purpose (tokenfuse's dearer dated rate for Gemini 3.x Flash; a long-prompt tier the vendor's page does not state, which is tier-not-on-page) is recorded in expectations/model-prices.json with its reason and printed as an EXCEPTION every run; an exception that excuses nothing is red, so a dated one has to be removed the day its price takes effect. Red for: a price that differs (with the row as it should read and the exception key a recorded choice would use), a model the vendor's page marks current missing from a table declared to price every current model (with the rows to add), a fallback below a current model's price, a page whose shape no longer parses, a table that cannot be read, a table none of whose rows maps to a vendor price, a stale or unreadable exception, and no declaration at all. A page that could not be fetched is NOT MEASURED and the run ends incomplete (exit 2), never partial. A tier the vendor's page does not state is NOT MEASURED the same way unless expectations/model-prices.json records it under tier-not-on-page; an undeclared one is counted, and the run exits 3 if its repository has no public remote or 2 if it has one. "Current" is what the vendor's page marks: Anthropic's rows above "Additional models", OpenAI's latest rows; Vertex AI marks none, so a Gemini model no table prices is not a current-model finding. Every model Vertex AI lists that no table prices is declared by cause, or NOT MEASURED and counted against the gateway's book. C23 does not read Bedrock's, DeepSeek's or AWS's own price lists, so the rows priced from those are declared not judged by cause, each one named by its id in expectations/model-prices.json. A row no cause declares is NOT MEASURED and counted, exit 2 in a repository with a public remote. C24, no owner quotes in any public repository (gates/c24-no-owner-quotes.py). Decided on 2026-10-13: every public repository is free of quotations of the owner and of the old provenance marker that carried his first name, and a decision is recorded as a date and a paraphrase in English. The gate reads the tracked files of every repository estate.json records a public GitHub remote for, and refuses four shapes on the line they occur on: the old marker, a guillemet quotation beside Cyrillic, Ukrainian prose outside a string literal (string literals in code files may carry Cyrillic, as hostile-input tests do, and so may a testdata/ file), and the owner's first name capitalised outside an authorship line. The owner as copyright holder, author or maintainer is allowed. Output names the file and the line number and never the line's text, because a CI log of a public repository is public too. A private repository is skipped, and the output says so. It does not catch an English quotation in ordinary quotation marks, an attribution that avoids his name, or a translation passed off as a paraphrase; those are prose a reader judges.

C25, each launcher keeps the security postures it sets today (gates/c25-deployment-posture.py). The 2026-10-17 review found that the products ship permissive defaults and rely on their launchers to turn enforcement on, and nothing read what the launchers set. The gate reads stack-single (compose.yaml and install.sh), stack-k8s (its manifests and install.sh) and stack-up (up.sh) at the ref the run is given, and judges twelve postures on each launcher that carries one: client keys required on the metered door, identity strict, the agent firewall, the gateway's policy-plane mode and fail mode, the gateway's policy-plane key role, the declassify key, the MCP broker's DLP and scan blocks, its policy-plane mode and fail mode, and the admin key. A posture is judged on the text the launcher sets, and each value is named with the line it came from. An anchor that matches nothing (a service, a Deployment, the wardryx-wired start of up.sh, the installer's role line) and a file that is gone are red, naming what vanished, never a skip. A launcher may lack a posture on purpose only when expectations/deployment-posture.json records why. That record is printed on every run, and it goes red when it has no reason or when it excuses nothing any more. The gate does not catch that a binary enforces what its launcher sets, which is the product's own suite's question, nor the opt-in components' flags, the no-wardryx fallback in up.sh, or the opt-in OpenAI door of stack-single. The header of the gate states each of those limits.

C26, cosign image signatures carry the legacy bundle flags (gates/c26-cosign-legacy-bundle-flags.py). Decided on 2026-10-19, paraphrased: image signatures use the bundle format policy-controller reads. The gate reads every workflow file of every repository estate.json names, finds each cosign sign line (the image subcommand; sign-blob and attest are not judged), reads its command with its continuation lines, and requires both --new-bundle-format=false and --use-signing-config=false. A line missing either is red, named by repository, file and line. Finding no sign line at all is measured nothing, not a pass. It does not read flags set some other way than on the command line.

Running it

Locally, against the sibling checkouts in the parent directory:

./run-gates.py                      # every gate, summarised
./gates/c4-event-registry.py        # or one on its own
./selftest.py                       # prove the gates can fail
./scripts/no-long-dashes.sh --prove

Three sources, and every run prints which one it used:

./run-gates.py                          # the working trees, as they are now
./run-gates.py --mode ref --ref origin/main   # the estate as published
./run-gates.py --mode clone             # fresh shallow clones, what CI does

--mode ref is the one to reach for while other people are editing: a working tree mid-edit will report drift that is somebody's uncommitted fix, and origin/main answers the question the nightly run answers.

Exit codes: 0 clean (every comparison agrees, and every subject was measured or is declared not judged by a recorded cause that names it; a declared subject is named and counted apart from the comparisons), 1 the estate drifted, 2 a repository that should have been reachable was not, a vendor price page or OpenRouter answer C23 reads could not be fetched or fully read, or a subject in a repository with a public remote went unmeasured with no recorded exception, 3 clean but every unread or unmeasured subject sits in a repository with no public remote. CI treats 3 as success with a notice and fails on everything else.

Shared tools for the service repositories

Some checks have to run inside the repository they judge, on its own pull requests, so this repository ships them as tools and reusable workflows that a caller pins to a commit. They are not cross-repository checks and nothing here runs them against another repository.

  • tools/secretscan and .github/workflows/secretscan.yml: no secret in a tree (invariant 23).
  • tools/diffcover and .github/workflows/diff-coverage.yml: the coverage floor on the lines a pull request adds, and the whole-repository baseline (invariant 28). See tools/diffcover/README.md for the rules, the measured-nothing cases and what the number does not say.

What is deliberately NOT covered

Written down rather than left implied, because an unstated gap reads as a covered one.

  • Repositories with no public remote. bank-in-a-box has only a private remote and no CI. In CI here it is reported as unmeasured and the run ends PARTIAL. taipan has been public since 2026-08-28 and is cloned and measured like the rest, so C4 reads the attribution of what taipan demo writes in CI as well, not only in local runs.
  • go.sum, replace targets and vendored Go code. C1 reads the version in go.mod. It reports the presence of a replace as a failure but does not follow it.
  • Event types built at runtime. Closed 2026-08-09. This said a type assembled from a variable would be missed with nothing complaining, and that no such producer existed. Both halves were wrong within the day: scopyx names its types as constants and passes the variable at the emit site, so C4 read it as a producer with no types and reported the run clean. The Go parsers now return what they could not resolve and the extractor refuses on it, so that shape is a loud hole. A type built from a format string is still beyond the parser, but it is now a REFUSAL rather than a silence.
  • The severity column of SPEC 6.2. The registry gives a typical severity per type in parentheses and tokenfuse fixes severity per type in code. Those are a fourth mirror and nothing compares them.
  • trailryx and catalog. In estate.json so that a future check has a place to start, read by nothing today.
  • Whether a claim is TRUE. Every check here compares two statements the estate makes about itself. Two documents that agree can still both be wrong, and no script can tell.
  • Anything an operator supplies at install time. C5 reads the defaults in the source. What a given box is running is not a property of a repository.
  • Whether a component starts BY DEFAULT. C5's services family answers "does this deployment bring this component up", and both heraldyx and scopyx are present in all three while being opt-in in two of them: excluded from stack-k8s's apply -k set, behind a compose profile in stack-single, and on by default only in the stack-up sandbox. C5 reports them as agreeing, which is true of the question it asks and not of the question a reader may think it asked. Noticed 2026-08-09 while adding scopyx; not modelled, because a family for it would have to distinguish "opt-in for a security reason" from "not wired up yet", and those look identical from the outside.
  • Prices nobody publishes where C23 reads. Bedrock's and AWS's own price lists, DeepSeek's page, and the data residency multipliers of first-party APIs. Rows priced from those are declared not judged by cause on every run, and a vendor page is the source of truth by definition: C23 cannot tell a wrong page from a right one.
  • The estate's OWN gates. Nothing here checks that the per-repo gates still run or still mean what they say. That belongs to each repository.
  • A coverage statement. estate.json lists the estate, and there is no machine-checked link between "repositories that exist" and "repositories any check reads". A repository could join the estate and be covered by nothing, and only a person reading this section would notice.

The gaps a check cannot hold

Everything above is what a script decides. GAPS.md is the standing register of what it cannot: the estate's open security findings, the seams no gate reaches yet, what agent monitoring can and cannot see, and the invariants every repository holds by prose alone.

It is a record rather than a source of truth. Nothing in it is authoritative about anything a command can decide, and its section 1 is a dated reading of a run-gates.py run rather than a second copy of one. Every item carries how to re-check it, because in this estate a file:line citation goes stale in days: on the day the register was opened, all three citations it tried to follow from a four-day-old audit had moved, and all three findings had been fixed.

Three more files record what the gates are not built to decide:

  • PROVEN.md records what has actually been run. Every row carries the date, the machine and the artifact, and states its scope, because a run proves what it ran and nothing adjacent. It is appended to by hand, one dated row per run, and never regenerated: it answers "has this ever been executed, and where", which is the question that keeps being answered from memory.
  • OUTSIDE-VIEW.md is the estate as a stranger finds it: repositories, container images and publish locations. It is regenerated by scripts/outside-view.py --write OUTSIDE-VIEW.md from estate.json and the image names the stack-k8s manifests pin, stamped with the moment it was taken, and never edited by hand.
  • evidence/ holds the raw artifacts individual PROVEN.md rows cite (today, the captures, probes and A/B scripts of the idryx eBPF sensor runs of 8 and 9 September), so a row can be re-opened rather than taken on trust.

Known uncovered mirrors

Copies that exist and are not compared, because they could not be anchored exactly and a guess is worse than an honest gap:

  • tokenfuse's event-type severities against 6.2's parenthesised ones, as above.
  • heraldyx's internal/render catalogue, which maps an event type to what it MEANS for an operator. Four of seventeen entries were found wrong in an audit. Nothing structural can check that an explanation is true.
  • The stack sentence and the component count that several repositories repeat in prose. Anchoring on English is how a gate learns to cry wolf.

A red badge here

means the estate drifted, not that this repository is broken. That is the whole design: every check compares two other repositories, and this one has no opinion of its own to be wrong about. When it is red, open the finding, and it will name both sides and the file to open.

Several checks are red today. That is correct, and working around any of them by weakening the check would be the one change this repository cannot survive.

C11, a proved chain was proved by something (gates/c11-proven-means-verified.py). wardryx decides on chain_proven, and its own comment states the trust boundary plainly: "a caller that lies about this is believed. That is not a weakness of this field, it is where the boundary is." That is correct, and it is exactly why the check belongs on this side of the boundary. The PDP cannot tell a verified true from an asserted one, and nobody can from inside wardryx: it is visible only to something holding wardryx and every producer open at once.

The failure it names is the one the 2026-08-25 identity plan called A5, and the dangerous half is not the one the plan led with. A chain wrongly marked unproven makes a policy fire that should not have, which somebody notices. A chain wrongly marked proved makes deny_if_chain_unproven stay silent, and an estate where that rule never fires looks exactly like an estate where every chain is proved.

So every non-test file that sets chain_proven true must CALL a verifier. A call and not an import: use tokenfuse_delegation::verify_delegation; at the top of a file that never calls it is what a refactor leaves behind, and counting it would let this pass on the wreckage. It reads text rather than data flow, so it sees a literal true and not a variable that happens to be one. That is the shape a rubber stamp actually takes, and the limit is written in the script rather than left to be discovered.

About

Cross-repo gates for the TAIPANBOX agent-governance estate: the checks no single repository can run on itself.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages