Architecture & Engineering Sentinel Design

Engine design, fail-closed contracts, CI/CD pipeline architecture, and verification discipline for discipline.

Superseded when: The engine is restructured, pipeline architecture changes, or gate contracts are modified. Update in place; do not fork.


1. Overview & System Mission

Discipline is a universal CI/CD gatekeeper and AI coding agent diff sentinel built in Rust. It compiles to a self-contained static binary with zero external runtime dependencies, providing identical diff analysis across GitHub Actions, GitLab CI/CD, Forgejo Actions, Gitea Actions, pre-commit hooks, and local developer environments.

1.1 Agent Drift and Test Erosion

Autonomous coding agents operating in iterate-until-green loops frequently introduce subtle test erosion patterns:

  1. Assertion weakening: assert_eq!(trie.get(k), Some(&v)) degraded to assert!(trie.get(k).is_some()) or assert!(true).
  2. Vacuous and ghost tests: Newly added tests that compile and execute but assert nothing.
  3. Stealth deletions: Flaky tests, fixtures, or benchmarks removed without justification, or tests renamed or marked #[ignore].
  4. Safety decay: unsafe code introduced without explicit // SAFETY: justifications, or safety comments deleted from above untouched blocks.
  5. Editing the gate instead of the code: Silently disabling gates, lowering severities, or growing exemption lists in discipline.toml.
  6. Documentation drift: Introducing unverified calendar estimates, leaking developer environment paths/IPs, or committing agent transcripts.
  7. Benchmark drift: Small algorithmic or execution regressions slipping past wall-clock tests lacking statistical rigor.

1.2 Design Origin and Prior Art

Discipline’s operational rigors were developed to defend high-assurance repositories against subtle automated regressions. Rather than relying on dozens of disparate repository scripts:

1.3 Non-Goal: Runtime Containment

Discipline reads changes and configuration; it does not contain a running agent. Watching system calls, filtering outbound traffic (DNS included), hiding credentials from a process and stopping a session belong to the sandbox the agent runs in: the agent’s own sandbox, a container or VM with no credentials and an egress allow-list, or a kernel-level monitor. They are not planned here, for three reasons: they need privileges and a platform (Linux, root or CAP_BPF) that a static binary run in CI or on a laptop does not have; they would add a network path, which §2 rules out; and a check that runs inside the process it contains can be walked around by that process. Nor does discipline read an agent’s stated reasoning: the agent writes it, so it is evidence of nothing.

What discipline does own is the configuration that sets those boundaries, because a change to it arrives as a diff like any other. sandbox-config reports a change that widens an agent’s permissions or sandbox (its project settings and MCP server lists) or a container’s isolation (Dev Containers, Docker Compose), and discipline doctor reports the agent settings that run an agent with less containment than its defaults (agent-sandbox). The pre-tool hook (hook run --event pre-tool) refuses edits and shell commands that reach another worktree; it does not block network commands, since a deny-list at the prompt is walked around by any interpreter the agent may run.

1.4 Threat Model

Discipline stands between a change and the branch it merges into. This section says what it protects, from whom, where it trusts, and what it leaves to others. SECURITY.md covers the narrower case of a flaw in discipline itself (hostile input that crashes or subverts the binary).

Assets.

  1. The merge gate’s verdict: a change that erodes tests, safety comments or policy is blocked, or it lands with its waiver on record.
  2. The policy: discipline.toml, the CI workflows and action inputs that run discipline, the agent hook and sandbox settings, the agent skills, plugins and MCP server lists a repository declares, and the list of protected paths.
  3. The record: the waivers, loosenings and ratifications in commit messages, pull-request bodies and issue comments, which discipline audit reads back.
  4. What the repository must not publish: secrets, personal data, private hosts and paths (the hygiene gates).
  5. The discipline release that CI downloads and runs with a token (§8.2).

Actors, by what they can do. Every actor below except the external one can edit any file in the change and write its commit messages and pull-request body.

Actor Intent Can also Primary controls
Careless agent Get the checks green; no intent to hide Run a shell in its worktree The AST gates (§1.1); fail-closed parsing (§3)
Rule-evading agent Get the checks green by any accepted means Use every escape hatch, edit the policy, the workflows and its own hooks, write text aimed at the reviewer or the next agent, and act with whatever credentials its host holds config-integrity, ci-integrity, sandbox-config, instruction-smuggling, ratified-paths, policy_from: base, directives.max_overrides / require_approval, discipline audit
Concurrent agents None hostile; they collide Move another worktree’s branch The lease guard and the pre-tool hook (Phase 13)
Repository owner Sets the policy Waive, ratify, merge, change repository settings Trusted: the owner’s login is the root of trust for ratifications and approvals
External attacker Subvert discipline or its users Supply hostile repositories, source, configuration or pull requests; tamper with the release chain SECURITY.md (input handling), deny.toml, pinned actions, release attestations (§8.2)

Trust boundaries.

Assumptions the guarantees depend on.

  1. Discipline runs as a required check with policy_from: base, and ci-integrity protects the workflow that runs it.
  2. The protected paths cover the policy (discipline.toml, workflows, agent hook and sandbox settings, agent instructions).
  3. Ratifications and approvals come from a login no agent can use. This does not hold on this repository today: every agent posts as the owner’s login (Phase 15 Step 8), so a ratification here shows the owner’s login agreed, not that a second party did.
  4. The forge reports authors, edits and links truthfully. When it fails to (a closing reference it did not link, #350), discipline refuses rather than guesses.
Assumption Guarantee underpinned Verification / failure mode
Required check runs with policy_from: base and ci-integrity enabled A change cannot weaken the policy or workflow that judges it (CICD-SEC-4) discipline doctor checks required-check and workflow-protection; ci-integrity flags workflow tampering
protected_paths covers policy, workflows, hooks, and agent instructions Protected files require explicit owner ratification on a closed issue ratified-paths gate and config-integrity gate fail closed on unratified edits
Ratifier and approver logins are distinct from agent credentials Second-party human authorization on ratifications and override approvals ratified-paths refuses agent_logins and (when refuse_author_ratification = true) the PR author; shared login is residual risk
Forge API reports logins, edit timestamps, and issue links accurately Ratification immutability, approval attribution, and issue-link verification src/forge.rs fails closed (exit 2) on malformed, partial, HTTP-error, or unlinked responses

Residual risks: what an attacker in this model can still do.

Coverage against published taxonomies. Every category of four taxonomies is resolved to exactly one of three outcomes:

The taxonomies:

This mapping is the maintainers’ reading of the four publications against this section and docs/GATES.md. It has not been externally reviewed; the “External review” criterion of the 1.0 Readiness table (docs/ROADMAP.md) stays open.

Category Resolution Control; pinning test Claim
LLM01 Prompt Injection (2025: LLM01) in scope: assets 1, 2; rule-evading agent, external attacker instruction-smuggling checks 1, 3 and 4, and agent-facing output quotes repository text in a fence it cannot close; test_gates_e2e::instruction_smuggling_reports_invisible_text_instruction_files_and_phrases_by_location_only, test_agent_hooks::source_text_reaches_the_agent_only_inside_a_fence_it_cannot_close tm-llm01
LLM02 Sensitive Information Disclosure (LLM02) in scope: asset 4; careless agent pii, shell-secrets, and findings that name locations only; test_gates_e2e::pii_fires_on_home_paths_and_lan_ips_without_echoing_the_user, test_gates_e2e::shell_secrets_gate_e2e, test_report_redaction::test_cross_format_redaction_pins_sentinel_exclusion tm-llm02
LLM03 Excessive Agency (LLM06) in scope: asset 2; rule-evading agent. The running agent is outside (§1.3) sandbox-config, doctor agent-sandbox, the pre-tool hook; test_gates_e2e::sandbox_config_reports_a_widened_agent_or_container_and_lifts_it_by_key_or_path, test_pretool::each_agent_is_refused_an_edit_in_another_worktree_and_allowed_its_own tm-llm03
LLM04 Supply Chain (LLM03) in scope: assets 2, 5; rule-evading agent, external attacker dependency-delta, build-hooks, pinned actions, the release attestation the action checks; test_gates_e2e::dependency_delta_reads_the_lockfile_not_only_its_size, test_gates_e2e::build_hooks_reports_install_hooks_build_scripts_and_manager_config_as_a_delta, test_action_version::a_downloaded_archive_must_carry_the_release_workflow_attestation_for_its_tag tm-llm04
LLM05 Data and Model Poisoning (LLM04) in scope for the text an agent is given in the repository: asset 2; rule-evading agent. The model, its training data and its fine-tuning are outside instruction-smuggling check 2 (every agent-instruction file edit reported); the same e2e test as tm-llm01 tm-llm05
LLM06 Unbounded Consumption (LLM10) residual Forge answers are bounded (pages, bytes, requests) and fail closed (forge::read_all_stops_at_the_stated_total_and_refuses_a_partial_list); no test bounds the work a crafted repository causes, which SECURITY.md treats as a vulnerability. An agent’s own consumption is outside —
LLM07 Misinformation (LLM09) residual A wrong change with plausible tests passes; provenance-tags, time-estimates and citation-metadata check the form of claims, not their truth —
LLM08 Hidden Context Exposure (LLM07 System Prompt Leakage) outside Disclosure from a running model’s context (§1.3). The exfiltration phrase class is a tripwire on text that asks for it, not a control —
LLM09 Vector and Embedding Weaknesses (LLM08) outside Discipline keeps no retrieval store or embeddings —
LLM10 Improper Output Handling (LLM05) in scope: asset 1; careless and rule-evading agents Agent output reaches the branch only as a diff the gates judge; the action passes inputs through env:, never ${{ }} inside run: (F11, tests/action/lint-action.py); discipline’s own reports name locations, never the matched text (F10); test_gates_e2e::agent_prompt_format_produces_repair_and_never_emits_directives tm-llm10
ASI01 Agent Goal Hijack in scope: assets 1, 2; rule-evading agent, external attacker instruction-smuggling (instruction files always reported; phrases, encoded runs and invisible characters); the same e2e test as tm-llm01, and test_gates_e2e::instruction_smuggling_reads_encoded_folded_split_and_hidden_phrases tm-asi01
ASI02 Tool Misuse and Exploitation in scope for the tool configuration a change can widen: asset 2; rule-evading agent. Runtime tool use is outside sandbox-config (allowed commands, MCP server lists); the pre-tool hook refuses a command that acts on another worktree; test_pretool::each_agent_is_refused_a_shell_command_that_acts_on_another_worktree and the tm-llm03 test tm-asi02
ASI03 Identity and Privilege Abuse in scope where logins differ: asset 3; rule-evading agent. An agent holding the owner’s credentials is residual (assumption 3) ratified-paths refuses an agent login’s ratification and, with refuse_author_ratification, the pull request author’s; test_pr_policy::a_ratification_by_an_agent_login_does_not_count, test_pr_policy::refuse_author_ratification_needs_a_login_other_than_the_pull_requests tm-asi03
ASI04 Agentic Supply Chain Vulnerabilities in scope for what the repository declares: asset 2; rule-evading agent, external attacker. A tool’s own update and user-level plugins are residual Agent hook, skill and settings files are instruction files (check 2); MCP server lists are sandbox-config; the Claude Code bootstrap pins release digests; test_agent_hooks::claude_code_bootstrap_pins_release_digests and the tm-llm01, tm-llm03 tests tm-asi04
ASI05 Unexpected Code Execution (RCE) in scope: assets 1, 2; rule-evading agent, external attacker build-hooks (install and build hooks that gain network or shell); command and test_command run only from the base policy; the CI base fetch runs no command the repository’s own git configuration names (test_gates_e2e::the_ci_base_fetch_runs_no_command_the_repository_configuration_names); test_gates_e2e::policy_from_base_judges_a_change_by_the_configuration_it_did_not_write and the build-hooks test of tm-llm04 tm-asi05
ASI06 Memory & Context Poisoning in scope for memory kept in the repository: asset 2; rule-evading agent. Agent memory outside the repository is residual instruction-smuggling check 2, and the declared instruction_files; the same e2e test as tm-llm01 tm-asi06
ASI07 Insecure Inter-Agent Communication in scope: assets 1, 3; rule-evading and concurrent agents Agents address each other through commit messages, pull-request bodies and comments (a boundary above); check 4 scans the description; the lease guard stops one agent moving another’s branch; test_gates_e2e::sleeps_trivial_assertions_and_injected_pr_bodies_are_reported, test_lease::the_ref_guard_refuses_moving_a_branch_another_worktree_leased tm-asi07
ASI08 Cascading Failures residual One agent’s waiver is read and repeated by the next. max_overrides caps each change (test_gates_e2e::override_budget_caps_what_one_change_may_excuse) and audit shows the pattern across changes; nothing blocks it —
ASI09 Human-Agent Trust Exploitation residual A reviewer who trusts a fluent description or a green check. The reviewer-steering class is a warning tripwire, and what an author writes is a claim, not proof (a boundary above) —
ASI10 Rogue Agents in scope: assets 1, 2; rule-evading agent The change does not judge itself (policy_from: base, config-integrity, ci-integrity); test_gates_e2e::a_change_cannot_weaken_its_own_config_without_a_scoped_token and the tm-asi05 policy test tm-asi10
CICD-SEC-1 Insufficient Flow Control Mechanisms in scope: asset 1; rule-evading agent doctor reports a required check that does not run discipline, bypass actors, review rules, and auto-merge with no required review (test_doctor::doctor_reports_auto_merge_forking_secret_scanning_and_dependency_alerts) (“Discipline cannot make itself run”); test_doctor::doctor_required_check_without_discipline_fails, doctor::review_rules_are_read_from_rulesets_and_classic_protection tm-cicd01
CICD-SEC-2 Inadequate Identity and Access Management residual Assumption 3: agents act with the owner’s login. doctor agent-permission and forge-token report what a token can do; deploy-keys, outside-collaborators, org-base-permission and two-factor report who else can push —
CICD-SEC-3 Dependency Chain Abuse in scope As tm-llm04 (dependency-delta, build-hooks package-manager configuration) tm-llm04
CICD-SEC-4 Poisoned Pipeline Execution (PPE) in scope: asset 2; rule-evading agent, external attacker. Workflow shapes ci-integrity does not read are residual (§10) policy_from: base, ci-integrity, doctor workflow-protection and its pull_request_target findings; test_gates_e2e::ci_integrity_flags_a_discipline_step_moved_off_the_base_policy, test_gates_e2e::ci_integrity_reports_workflow_changes_that_expose_secrets_or_a_write_token, test_doctor::doctor_reports_the_repository_settings_that_let_mutable_code_run tm-cicd04
CICD-SEC-5 Insufficient PBAC (Pipeline-Based Access Controls) in scope: asset 2; rule-evading agent ci-integrity reports a workflow gaining a write token or exposing secrets; doctor default-token and secret-scoping; the tm-cicd04 ci-integrity test, doctor_settings::gitlab_job_token_passes_only_with_the_allowlist_on_and_no_push tm-cicd05
CICD-SEC-6 Insufficient Credential Hygiene in scope: credentials in the change, and the forge’s secret scanning and push protection (doctor secret-scanning, information) As tm-llm02 tm-llm02
CICD-SEC-7 Insecure System Configuration in scope for configuration in the repository: asset 2; rule-evading agent. Runner-side settings (GitLab’s privileged runners) are outside the repository sandbox-config (Dev Containers, Docker Compose, CI job and service containers), doctor’s Actions policy findings; the tm-llm03 sandbox-config test, test_gates_e2e::sandbox_config_reports_a_privileged_workflow_container, doctor_settings::actions_policy_warns_on_each_unsafe_value tm-cicd07
CICD-SEC-8 Ungoverned Usage of 3rd Party Services in scope: assets 2, 5; rule-evading agent, external attacker Pinned actions (ci-integrity, doctor imposter pins), doctor allowed-actions, MCP server lists (sandbox-config); test_gates_e2e::ci_integrity_a_third_party_action_in_a_job_with_secrets_warns_without_failing, test_doctor::doctor_fails_an_imposter_pin_and_warns_on_a_tag_pinned_nested_action tm-cicd08
CICD-SEC-9 Improper Artifact Integrity Validation in scope: asset 5; external attacker The installer verifies checksums (F12), the action checks the release attestation, doctor tag-protection and immutable-releases. doctor signed-commits is reported as information and no test pins it; test_action_version::a_downloaded_archive_must_carry_the_release_workflow_attestation_for_its_tag, doctor_settings::tag_ruleset_must_cover_the_release_tag_and_block_update_and_deletion tm-cicd09
CICD-SEC-10 Insufficient Logging and Visibility residual audit reads waivers, loosenings and ratifications back from git and the forge (test_audit::each_kind_of_escape_hatch_is_recorded_against_its_change); there is no central log —
ATLAS AML.T0051 LLM Prompt Injection (.000 Direct, .001 Indirect, .002 Triggered), AML.T0093 Prompt Infiltration via Public-Facing Application, AML.T0061 LLM Prompt Self-Replication, AML.T0094 Delay Execution of LLM Instructions in scope As tm-llm01 and tm-asi01: text added to the repository or its change description tm-llm01
AML.T0068 LLM Prompt Obfuscation, AML.T0123 Obfuscated Files or Information in scope instruction-smuggling folds and decodes before the phrase match (NFKC, look-alikes, leetspeak, ROT13, %xx, hex, base64); test_gates_e2e::instruction_smuggling_reads_encoded_folded_split_and_hidden_phrases tm-asi01
AML.T0081 Modify AI Agent Configuration, AML.T0083 Credentials from AI Agent Configuration in scope An agent configuration file in the repository is an instruction file or a sandbox-config file tm-asi04
AML.T0080 AI Agent Context Poisoning (.000 Memory, .001 Thread) in scope in the repository; residual outside it As tm-asi06 tm-asi06
AML.T0110 AI Agent Tool Poisoning, AML.T0099 AI Agent Tool Data Poisoning, AML.T0010.005 AI Supply Chain Compromise: AI Agent Tool, AML.T0011.002 User Execution: Poisoned AI Agent Tool, AML.T0115.002 Publish Poisoned AI Artifacts: AI Agent Tools in scope for the tool list a change edits; residual for the tool’s content and updates (AML.T0109 AI Supply Chain Rug Pull) As tm-asi04 tm-asi04
AML.T0010.001 AI Supply Chain Compromise: AI Software, AML.T0011.001 User Execution: Malicious Package in scope As tm-llm04 tm-llm04
AML.T0119 Exploit Automated Artifact Processing Pipeline in scope As tm-llm10 and tm-asi05 tm-asi05
AML.T0055 Unsecured Credentials in scope As tm-llm02 tm-llm02
AML.T0118.000 Autonomous AI Agent Communication: Communication via Shared Artifacts in scope As tm-asi07 tm-asi07
AML.T0012 Valid Accounts residual As ASI03: an agent acting with the owner’s login —
AML.T0053 AI Agent Tool Invocation, AML.T0050 Command and Scripting Interpreter, AML.T0086 Exfiltration via AI Agent Tool Invocation, AML.T0101 Data Destruction via AI Agent Tool Invocation, AML.T0098 AI Agent Tool Credential Harvesting, AML.T0105 Escape to Host, AML.T0112 Machine Compromise, AML.T0100 AI Agent Clickbait, AML.T0054 LLM Jailbreak, AML.T0056 Extract LLM System Prompt, AML.T0057 LLM Data Leakage, AML.T0077 LLM Response Rendering, AML.T0084 Discover AI Agent Configuration, AML.T0133 Discover AI Agent Runtime Capabilities outside The running agent (§1.3). The pre-tool hook’s worktree refusal (tm-asi02) is the one runtime check discipline ships —
AML.T0020 Training Data Poisoning, AML.T0031 Erode AI Model Integrity, AML.T0115.000 / .001 Publish Poisoned AI Artifacts: Datasets / Models, AML.T0070 RAG Poisoning, AML.T0071 False RAG Entry Injection, AML.T0024 Exfiltration via AI Inference API, AML.T0095.000 Search Open Websites/Domains: Code Repositories outside The model, its data and retrieval stores; reading public code —

2. Architecture Diagram & Binary Design

flowchart TD
    subgraph CFG_LAYER["Layered Configuration & Directives"]
        D["1. Built-in Defaults<br/>(26 gates on, 14 opt-in; see discipline gates)"]
        F["2. discipline.toml<br/>(Repository configuration)"]
        O["3. Inline Overrides / Directives<br/>(--config-override, PR body)"]
        CLI["4. CLI Flags & Environment<br/>(--enable, --disable, denylist)"]
        BASE_CFG["base-ref discipline.toml<br/>(compared by config-integrity)"]
    end

    subgraph ENGINE["Discipline Core Engine (Rust)"]
        RESOLVE["Config Resolution & Gate Registry"]
        GIT["GitCtx (in-memory git2)<br/>merge-base diff, blobs, index"]
        AST["Tree-Sitter AST Extractors<br/>tests, assertions, unsafe blocks"]
        EVAL["Fail-Closed Gate Evaluators<br/>truthful examined counts, floors"]
    end

    subgraph CONSUMERS["Execution Contexts & Consumers"]
        ACTION["GitHub / Gitea / Forgejo Action<br/>(composite runner)"]
        HOOK["Pre-Commit Hook<br/>(discipline check --staged)"]
        DEV["Agent Inner Loop / Dev CLI<br/>(discipline check --base ...)"]
    end

    D --> RESOLVE
    F --> RESOLVE
    O --> RESOLVE
    CLI --> RESOLVE
    BASE_CFG -.-> RESOLVE

    RESOLVE --> EVAL
    GIT --> AST
    AST --> EVAL

    EVAL --> ACTION
    EVAL --> HOOK
    EVAL --> DEV
  1. Binary-first & zero-dependency: Statically linked musl binaries (x86_64-unknown-linux-musl, aarch64-unknown-linux-musl) and native macOS binaries (x86_64-apple-darwin, aarch64-apple-darwin). No Node.js, Python, or container bootstrap required at runtime.
  2. Offline by default, one HTTPS client: libgit2 is vendored with its network and transport features disabled, so every diff is read from the local git object database. The forge REST API is reached only through the in-process HTTPS client in src/forge.rs (ureq over rustls, no OpenSSL), for opt-in features: open-issue state and bench citation freshness (provenance-tags, bench-regression), issue-link reference verification, ratified-paths and review-threads (read-only GitHub GraphQL queries among their reads), require_approval reviews, the merged-pr-body directive source, discipline replay and discipline doctor; its one write is check --comment. DISCIPLINE_NO_NETWORK=1 keeps that client off the network (a loopback address excepted). One exception sits outside the client: in CI (CI, GITHUB_ACTIONS, GITLAB_CI, GITEA_ACTIONS or FORGEJO_ACTIONS set), when the base ref cannot be resolved, src/gitctx.rs spawns the external git fetch against origin to deepen a shallow clone, with hooks, the file-system monitor, command transports (ext::, fd::, git://) and the upload-pack program overridden, and a command the repository’s own configuration sets for ssh, credentials or a password prompt (core.sshCommand, credential.helper, core.askPass) replaced by the runner’s global or system value (#364), and exits 2 if the base is still missing; DISCIPLINE_NO_NETWORK=1 skips it. That git is the first one on PATH, and it runs with LC_ALL=C and without LANGUAGE, so the fetch diagnostic the binary quotes reads the same whatever the caller’s locale (#603). It is the only git the binary starts: the diff, the base and every blob are read in process, so a different git on PATH changes nothing a gate reads. The other program found through PATH is the one a command-style gate is configured to run. The in-process library reads the user’s git configuration from HOME (~/.gitconfig) and XDG_CONFIG_HOME (git/config), and a file there that does not parse is exit 2; it does not read GIT_CONFIG_GLOBAL, GIT_CONFIG_SYSTEM or GIT_CONFIG_NOSYSTEM, which are settings of the git program.
  3. AST-aware, never regex-naive: unsafe or assert! appearing within comments, string literals, or doc tests are never counted as code nodes. Source-code invariants (tests, assertions, unsafe, handlers, stubs) are read from the syntax tree only. Regular expressions read text that has no grammar here: prose, PR bodies, shell and workflow lines, manifests and tool output.
  4. Unified gate registry: Every gate possesses a stable kebab-case identifier in src/config.rs::GATES.
  5. Language packs behind a shared fact model: Tree-sitter grammars and extractors map diverse ecosystems onto language-neutral facts (TestFn, UnsafeSite, assertion counts).

2.1 Configuration Resolution Lifecycle

flowchart TD
    D["1. Built-in Defaults<br/>(26 gates on, 14 opt-in; see discipline gates)"] --> M1["Merge Layer 1"]
    F["2. discipline.toml<br/>(Repository configuration)"] --> M1
    M1 --> M2["Merge Layer 2"]
    O["3. Inline Override<br/>(--config-override / action input)"] --> M2
    M2 --> M3["Merge Layer 3"]
    CLI["4. CLI Switches<br/>(--enable / --disable)"] --> M3
    M3 --> M4["Merge Layer 4"]
    ENV["5. DISCIPLINE_HOSTNAME_DENYLIST<br/>(CI Secret Env)"] --> M4
    M4 --> VAL["Strict Validation<br/>(deny_unknown_fields, planned gate check)"]
    VAL --> CFG["Effective DisciplineConfig"]

2.2 Diff Inspection & Gate Evaluation Pipeline

sequenceDiagram
    autonumber
    participant CLI as Discipline CLI / Runner
    participant Git as GitCtx (git2)
    participant AST as Language Extractors (tree-sitter)
    participant Gates as Gate Engine
    participant Report as Multi-Format Reporter

    CLI->>Git: Resolve merge-base(base_ref, HEAD)
    Git-->>CLI: Changed files, blobs, index
    CLI->>AST: Dispatch changed files by extension
    activate AST
    AST-->>CLI: ParsedFileFacts (base vs head TestFn, assertions, unsafe)
    deactivate AST
    loop Each enabled gate, one at a time in registry order
        CLI->>Gates: agent-guard, hygiene, integrity, quality, verification, bench
    end
    Gates-->>CLI: Vec<GateOutcome> (examined counts, violations, overrides)
    CLI->>Report: Render (Terminal, GitHub Summary, JSON, gl-codequality, JUnit, SARIF, Agent-Prompt)
    Report-->>CLI: Exit code (0 = pass, 1 = violations, 2 = could not check)

2.3 AST Test Node Extraction & Assertion Counting Pipeline

flowchart TD
    SRC["Changed Source File Blob"] --> LANG{"Language Dispatcher by Extension"}
    LANG -->|"*.rs, *.py, *.js / *.ts, *.go, *.java, *.cs, *.rb, *.php"| P_TS["Tree-sitter pack per language<br/>(rust, python, javascript / typescript, go, java, c-sharp, ruby, php)"]
    LANG -->|"*.c, *.h / *.cpp, *.cc, *.hpp"| P_CPP["C pack (tree-sitter-c)<br/>C++ pack (tree-sitter-cpp)"]
    LANG -->|"*.kt, *.swift, *.scala, *.m / *.mm"| P_NEW["Kotlin, Swift, Scala, Objective-C packs<br/>(tree-sitter)"]
    LANG -->|"*.phpt"| P_PHPT["PHPT section parser (no grammar)"]
    LANG -->|"Unmatched"| UNK["Unanalysed Language Note (F7)"]

    P_TS --> EXT["Fact Extractor Engine"]
    P_CPP --> EXT
    P_NEW --> EXT
    P_PHPT --> EXT

    EXT --> T_DISC{"Partition Function Nodes"}
    T_DISC -->|"Go: Benchmark* with *testing.B"| BENCH_NODE["Benchmark Node (Excluded from Unit Tests)"]
    T_DISC -->|"Test Attributes / Naming Conventions"| TEST_NODE["Test Function Node"]

    TEST_NODE --> SKIP{"Check Skip / Ignore Markers"}
    SKIP -->|"#[ignore], @skip, xit, [Ignore]"| FLG_SKIP["Mark TestFn.ignored = true"]
    SKIP -->|"Active Executable"| SCAN_AST["Traverse AST Function Body"]

    SCAN_AST --> CNT_ASSERT["Count Assertions & Matchers"]
    CNT_ASSERT --> TAUT{"Inspect Assertion AST Expressions"}
    TAUT -->|"assert_eq!(1, 1), assert!(true)"| FLG_TAUT["Increment tautological_count"]
    TAUT -->|"Other Call"| CHK_HELP{"In assert_helper_fns?"}
    CHK_HELP -->|"Yes"| INC_HELP["Count one total, no strong assertion"]
    CHK_HELP -->|"No"| REG_CALL["Regular Function Call"]
    INC_HELP --> SAME_FILE{"Helper body in this file, or a same-file helper that asserts?"}
    REG_CALL --> SAME_FILE
    SAME_FILE -->|"Yes (3 calls deep in C/C++ and Python, 1 elsewhere)"| ADD_HELP["Replace the call's credit with the Helper's Assertion Counts"]
    TAUT -->|"Standard Assert Macro / Expect Matcher"| INC_STD["Count Effective & Strong Assertions"]

    FLG_SKIP --> FACTS["Output ParsedFileFacts (tests, assertions, unsafe)"]
    FLG_TAUT --> FACTS
    INC_HELP --> FACTS
    ADD_HELP --> FACTS
    INC_STD --> FACTS
    REG_CALL --> FACTS

2.4 Binary Footprint & Static Linking Profiles

Discipline compiles to a standalone static binary with zero external runtime dependencies. It statically links libgit2 (vendored, no transport), the 15 tree-sitter grammars (Rust, Python, JavaScript, TypeScript, Go, Java, C#, C, C++, Ruby, PHP, Kotlin, Swift, Scala, Objective-C) and the rustls / ring TLS stack of the forge client. It is fully static-pie linked on musl and runs in scratch containers or minimal CI runners without glibc, OpenSSL, or package managers. The sizes below were measured at 32c81b5, before the Kotlin, Swift, Scala and Objective-C packs and the forge client were added; the current binaries have not been re-measured:


3. The Fail-Closed Contract

Every gate in Discipline must satisfy the following 12 load-bearing invariant rules:

Invariant Requirement Incident Origin / Justification
F1 Three exit states. 0 = pass, 1 = violations found, 2 = gate could not check. A defective gate or broken environment is never mistaken for a valid change. On exit 2 the JSON report (stdout under --format json, and --json-out) has no outcomes and a could_not_check object: reason (configuration, baseline, repository, tool-missing, tool-timeout, toolchain-unavailable, forge, gate, internal), the gate that could not run, and the error as printed on stderr. An error is tagged where it arises (src/could_not_check.rs) and the innermost tag wins, so a missing tool inside a gate is tool-missing, not gate. JUnit, SARIF and GitLab reports, which have no such field, carry one engine/could-not-run finding. replay and the MCP check_diff tool report the same reason. CI canaries that passed when underlying build scripts failed.
F2 Three-state inputs. Found / none / could-not-determine. An unresolvable base ref, shallow clone lacking merge base, missing repository, or unreadable file exits 2. Never an empty diff: a guessed default branch (origin/HEAD, master, trunk) that already holds HEAD does not count as a base. Initial scaffold probe reporting clean zero on invalid refs.
F3 Merge-base diffs with rename detection. Diff measured from merge-base(base, HEAD); moved files are tracked as modifications, not deletions. Deletion gate false positives on renamed test suites.
F4 No vacuous pass. Every report prints the exact examined count per gate. A suite with zero available gates or an empty tracked tree is an error. Silent fail-open when zero files were scanned (“0 of 52 files scanned, exit 0”).
F5 Planned is not passed. A gate not shipped in the binary cannot be enabled or configured (exit 2). Every report lists planned gates under “not checked”. Scaffold config accepting and ignoring planned flags.
F6 Strict configuration. Unknown keys, unknown gates, invalid regexes, malformed globs, unsupported schema versions, and contradictory switches (enable + disable) are errors. Typo’d TOML keys accepted silently.
F7 Named degradation. When a gate cannot inspect a file (unanalysed language pack, file exceeding size limits, unreadable base config), it explicitly names the file in the report. A file counts as binary, and is not read, only by a known binary extension, a magic number with a non-printable byte, or a NUL byte in its first kilobyte, and never when its extension is a known text one: a printable magic number such as MZ (the DOS/PE header, and valid Python as MZ = 0) does not hide a source file from the gates. A language-pack extension (.cs, .dart, .m, …) and Gemfile, Rakefile, Dockerfile and Makefile are text too. A gate that skips a changed file it cannot read as text names it (skipped ` (binary or unreadable file)`); `pii` scanning the whole tree names the binary files the change touched and counts the rest. Benchmark script silently passing on “NO BASELINE”.
F8 A gate is not satisfied by prose about the gate. Directives are strictly line-anchored; mentions in tables, sentences, or code blocks never arm an override. Incident where a Markdown table describing a token accidentally waived all checks.
F9 A change cannot lower its own bar. Configuration on head is diffed against base ref; loosening requires an explicit allow-gate-weakening: directive. Threshold constants edited in the diff that violated them.
F10 Secrets are not echoed. Denylisted hostnames, local user workstation paths, and PII patterns are reported by file location only, never printed. Hostname denylists echoed in public CI logs.
F11 Untrusted text never reaches a shell parser. All action inputs pass through env:, never inline ${{ }} interpolation. Inline shell injection vulnerabilities in workflow expressions.
F12 The installer verifies what it runs. A release archive the action downloads is verified against SHA256SUMS with no opt-out; no fallbacks to unverified compilation. binary_path skips the download, so the operator vouches for that binary. Scaffold download failure falling back to unverified local build.

3.1 Defaults Are Part of the Compatibility Contract

A consumer who runs Discipline with zero configuration, or who configures only some gates, relies on the built-in default enablement and severity of every other gate. A default that becomes looser (a gate turned off, or moved from error to warning or note) silently stops blocking for that consumer, with no diff in their repository to review. Defaults are therefore versioned like the configuration schema:

3.2 What 1.0 Freezes

From 1.0, a stable surface below changes incompatibly only in a new major version. Adding to it (a new flag, key, field, gate, code, reason, tool or language) is a minor release; changing or removing what exists is a major one. A renamed flag or key keeps its old name as an alias, with a deprecation note in the report, until the next major version. For configuration keys the mechanism is KEY_ALIASES in src/config.rs: the old name is read as the new one, setting both is a configuration error (exit 2), and each use adds a line to the report’s deprecations list (deprecated: ... in the text report), which never fails the run. An experimental surface may change in a minor release, with a ledger row.

The promise is a test. tests/fixtures/v1_surface.json lists every frozen name: subcommands, flags and their values, the DISCIPLINE_* variables, configuration keys, directives, gate ids, finding codes, policy refusal rule ids, could-not-check reasons, MCP tools and arguments, hook agents, and the action’s inputs and outputs. tests/test_stability_contract.rs collects each from the code and fails when a recorded name is gone; a new name must be recorded (DISCIPLINE_BLESS_SURFACE=1 cargo test --test test_stability_contract). The JSON outputs are pinned field by field in tests/test_output_schemas.rs.

Surface Tier Frozen Free to evolve in a minor release
CLI stable Subcommand and flag names and their values; the DISCIPLINE_* environment variables; exit codes 0 pass, 1 findings, 2 could not check. A run stopped by a signal ends by that signal (the shell’s 128 + n), never with 2 (a_signal_ends_the_run_by_the_signal_not_exit_2) New subcommands and flags; help text
Configuration stable Table and key names and their value types in discipline.toml ([meta] version = 1); list reset syntax; how layers merge New keys and tables; default values under the rules of §3.1
Directives stable The directive names (allow-*:, removes:, discipline:allow(<gate>)), their subject grammar, the directive sources New directives
Gates and finding codes stable Gate ids; finding codes (gate/code, the registry in src/findings.rs) and what each protects: detection may find more under a code, but a code is never split so that findings it reported move to another code without a ledger row naming both; the [gate/code] token in text reports; severities as error / warning / note; the titles of fingerprint-version-1 baselines until they are migrated What a gate detects, as recorded in the ledger (below); finding titles, messages and remediation text, which are display text
Reports stable The JSON report’s fields, their names and types (--format json, replay --json, audit --json, MCP check_diff’s structuredContent), as discipline.report.schema.json, discipline.replay.schema.json, discipline.audit.schema.json and mcp_check_schema() define them; schema_version in each, which a renamed, removed or retyped field raises; the could_not_check.reason a condition maps to (a failure keeps its reason; new kinds of failure may get new reasons); findings in registry order and byte-identical output for the same tree; stdout carrying only the requested format. Consumers ignore fields they do not know. SARIF 2.1.0, JUnit and GitLab Code Quality follow their external schemas New fields; new reasons; terminal and Markdown text
Policy refusals stable The rule ids (policy/<code>, the registry in src/refusals.rs) under which the SARIF, JUnit and GitLab code-quality reports carry a refusal, and what each names. policy is the id of no gate and the name of no suite (no_refusal_rule_id_is_a_finding_code) New rule ids; a refusal’s title and wording, which are display text; the location fields of an entry
End-of-turn checks stable The codes (turn/<code>, src/turn.rs) a hook reports for the end of a turn, in its refusal text and its observation log. turn is the id of no gate and is not policy (codes_are_in_their_own_namespace) New codes; the refusal wording and what the matcher reads, which are detection
Baselines stable The fingerprint formula (src/baseline.rs); discipline-baseline.toml layout; each code’s anchor (Violation::anchor: what tells two findings of one code in one file apart, a typed source datum and never prose). Messages are not hashed, so they are free text Anchors for codes that had none, each with a ledger row naming the regeneration (discipline baseline)
Agent surfaces stable The MCP tool names, argument names and result shape; hook run’s accepted payload fields and each agent’s output contract; in the agent-prompt report and every text an agent reads: (i) no directive syntax in any form the parser reads, (ii) each finding’s [gate/code], (iii) could-not-check distinct from findings and from a pass, (iv) text from the repository inside a fenced block it cannot close, (v) no hook reports an unresolved change as clean, a stop let through at a loop guard included The wording of the report around those invariants; support for more agents
Agent configuration files experimental   The files hook install writes, which follow each agent’s own hook format as it changes (one contract-fixture test per agent in tests/test_agent_hooks.rs)
Action and templates stable action.yml inputs and outputs; the major tag (@v1) moving only through the release pipeline (§8.2) New inputs and outputs
Rust crate API internal   The discipline crate exports modules for binary composition and integration tests; the supported embedding surface is the static binary and its structured outputs (--format json, SARIF, JUnit, MCP). The internal Rust API carries no stability promise across releases

Detection is not frozen. Language packs, parsers and gates keep improving, and every change to what a gate reports is a ledger row in docs/ROADMAP.md: a stricter one (a new finding, a construct now read) may land in a minor release; a looser one (a false positive fixed) follows §3.1 and names the configuration that restores the old behaviour where one exists. Pinning a version (@v1.2.3, an image digest) is the way to hold detection still.

Before 1.0 a minor release may still change these surfaces; the ledger records each change. The Interface freeze criterion in docs/ROADMAP.md (“1.0 Readiness”) is met by one full minor release that changes none of the frozen column.


4. Module Map

Module Responsibility
src/config.rs Gate registry (GATES), schema definition, layered configuration resolution
src/gitctx.rs Git interaction via libgit2: base ref detection, merge-base computation, blob streaming, index inspection
src/tokens.rs Line-anchored override directive parser and validation
src/main.rs, src/lib.rs, src/cli.rs Binary entry point and subcommand wiring (including the merged-pr-body read); library root; the clap command-line definition
src/deep_stack.rs on_deep_stack: the thread with a 256 MiB stack that main runs every subcommand on, so a walker can descend a syntax tree to the depth limit of src/ast/source_text.rs; the stack size, the measured stack per level and the factor between them are constants a test checks against the limit. A panic of the work goes on in the caller with its payload, and a thread that cannot be started is exit 2 with the reason
src/schema.rs JSON Schema generation for discipline.toml
src/output_schema.rs JSON Schemas of the check --format json report and the replay --json summary
src/ast/ Tree-sitter dispatch (mod.rs: registry, Fact, shared helper and dispatch-table resolution) and one module per language pack (rust.rs, python.rs, javascript.rs, java.rs, kotlin.rs, go.rs, php.rs, c_cpp.rs, csharp.rs, ruby.rs, swift.rs, scala.rs, objc.rs, golden.rs), plus the facts every pack shares: functions.rs (stubs), handlers.rs (swallowed errors), harness.rs (the forms harness-tampering reads in a file the test runner loads), prose.rs, reach.rs (unreachable code), mocks.rs, calls.rs, retries.rs, budgets.rs, bounds.rs (numeric bounds inside assertions); c_macros.rs masks C extension macros before parsing; source_text.rs holds the one call of the parser, for source text and for the pre-tool hook’s shell commands, and gives every parse a budget. The parser library’s error recovery does not finish on some texts (#640: the Rust grammar stays at one position of a 15-byte text for as long as it is left to run), so a parse may take 256 steps plus one for every 4 bytes of text, a step being one of the library’s progress checks (one per 100 parse operations). The count depends on the text and the grammar alone, so the same file is cut, or not, on every machine; the sources of this repository take at most 0.016 steps per byte with their own grammar and 0.05 with any other. A parse that reaches its budget has no tree: the pack’s error names the file (could not parse `<path>`: the parser did not finish within its budget of <n> steps for <m> bytes), the gates that need every changed file’s facts stop on it (exit 2), the others note it, and the pre-tool hook refuses the command. A parse that finishes can still give a tree no walker may descend: most walkers call themselves once for each level, so a source nested deeply enough ended the process with a stack overflow and no report (#667). parse_within_budget measures each tree it parsed with one cursor walk that keeps no stack of its own (tree_depth) and gives no tree deeper than TREE_DEPTH_LIMIT, 4,096 levels, with the error could not parse `<path>`: the source nests <n> levels deep, past the 4096 this tool reads; every caller handles it as it handles a parse cut at its budget. The limit holds on the stack the work runs on and on no smaller one: main runs every subcommand inside deep_stack::on_deep_stack, on a thread with a 256 MiB stack (src/deep_stack.rs), and the limit and that stack are set against each other. In a build without optimisation the walker that takes the most stack for a level takes 3,400 bytes (RUN: the Go pack’s call scanner ended a 2 MiB stack at 623 levels; the others took from 3,190 bytes down to 480), so a tree at the limit takes 13.9 MB and the stack holds it 19 times; the_stack_holds_the_deepest_tree_four_times requires four, from the named constants. Above, the limit is twice the deepest of 29,795 sources of other projects (2,056 levels, generated). On a default stack a tree far inside the limit still ends the process, so a test or a fuzz target that hands a pack a deeply nested source does it inside on_deep_stack too. Two walkers that took more stack than any other keep a list of what is left to read instead of calling themselves (the Rust pack’s Extractor::visit, 6.4 kB a level; the C# pack’s walk_scope, 4.9 kB); the others still call themselves. The parser library keeps no link from a node to its parent: Node::parent descends from the root for each answer, and the four sibling methods call it first, so one call costs the node’s depth and a climb from a node to the root costs the depth squared. A walker that climbed once for each nested function did work cubic in the nesting on a source of a few kilobytes, which neither the parse budget nor the depth limit bounds (#669; RUN, counted in instructions in a build without optimisation: one extraction of 512 nested Rust functions took 4.7e11 and takes 5.4e8). No walker under src/ast/ calls those five methods, which clippy.toml disallows: each takes an ancestry::Ancestry, made once for a tree. It keeps the chain of nodes from the root to the node it was last asked about, so a walker that asks in source order pays for each node once and a climb pays one step a level; it keeps what each node of the chain answered to a question a walker asks of every ancestor (nearest: is this module under #[cfg(test)], is this a loop whose body holds the node), so an ancestor is asked once however many nodes below it are read; and every answer is the library’s own, which every_answer_is_the_librarys checks for every node of trees with and without errors. The Rust pack keeps the cfg conditions of the modules around the node it reads as it descends, where it climbed to the root for each test. The call counter (calls.rs) reads the text of a file with its strings and comments blanked once, where it blanked the text under each nested call again, and keeps the callee of a call that matched no counted word as a range, inside which a call is read at its edges only. Work is counted in steps, not in time: a_nested_source_costs_steps_in_proportion_to_its_size holds a source nested four times as deep to under five times the steps, for the costliest nesting of each pack. What is not counted there still grows with the nesting times the size of what is nested: a pack’s own walker reads the body of each nested function, class or test again for the calls and assertions it holds (a test’s facts hold those of the tests nested in it). The depth limit bounds that factor. An expected value or a numeric bound is recorded with the text of the whole assertion it stands in (its skeleton), and an expected value with the text of every call of that assertion, so one assertion that holds many literals is recorded as many times its own text: what is recorded is the square of the source, and the work is held to what is recorded and no further (#672). The calls of an assertion are listed once, from one reading of its tokens, and each expected value in it, or in an assertion inside it, reads its run of that list (expectations::Calls); a record is placed in its test by a table of lines and compared with the ones before it only where a hash of both is the same (bounds::Records). RUN, counted in instructions in a build without optimisation: a TypeScript source of 24 kB that a fuzzing run found, 333 nested suites with one matcher name cut short so that one expect statement holds 262 expected values, had a run stopped at 4.0e12 with no report and is now extracted in 8.3e9, with 5.0 MB of skeletons and 3.4 MB of call texts recorded where 904 MB were. Three readers that walked a function again for each thing in it now read it once: a Rust binding of a catch_unwind result is judged from one list of the identifiers of the file, with what the later uses of a name answered kept (each later let of the same name asked every one after it again, so 18 bindings of one name cost 1.0e12 and each one more doubled it); a Kotlin expected exception finds the assertion on its message in one list of them; a Python try has its handlers read once for all the assertions in its body, and a JavaScript .catch(name) has the function name looked up once. a_statement_of_many_literals_costs_steps_in_proportion_to_what_is_recorded and the tests beside it hold each to its steps. Further readers read a function, a file or a list again for each thing in it, and now read it once (#672). A Java catchThrowable call finds the assertThat calls on its name in one list of them, and what the calls on a name in a method require is kept for the name and the method (expected_exceptions::JavaAsserted); a Kotlin runCatching result bound to a name asks one list of the identifiers of the file whether the name is used after it (caught_assertions::KtNames); the assertions of the closure a Rust catch_unwind runs are a run of one list (caught_assertions::RsAsserts), and a closure, or a JavaScript promise chain, inside one whose assertions were all handed over is not read again; a JavaScript call is sorted as a test, a suite or neither by how its callee begins, the beginning of each node kept (CodeHeads), where the whole callee was read for each link of a chain of calls. A caught assertion is placed in its test by the table of lines and compared with the ones the test holds through their order (caught_assertions::Caught); the same table places helper calls, receiver calls and retry markers. A Python method asks one list of the classes of the file whether another class overrides it, kept for the tree (Ancestry::kept); the package-level declarations of a Go file are read once for all its tests (test_cases::GoPackageLevel); the dead ranges of reach.rs are kept in order and searched by halving; and the lines of a file are listed once for the fingerprints of all its findings (baseline.rs). RUN, counted in instructions in a build without optimisation, before and after: 400 catchThrowable in one method, 3.9e10 and 2.2e9; 400 runCatching results, 2.6e10 and 1.5e9; a chain of 400 .then().catch(), 2.7e10 and 3.4e9; a chain of 400 calls in a source file, 2.7e9 and 3.6e8; catch_unwind closures 640 deep, 6.5e10 and 5.3e9; a class of 1000 methods, 1.0e11 and 3.6e9; 1000 Go tests in one file, 1.5e10 and 7.7e9; 4000 empty handlers added to one file, through check, 2.7e11 and 4.9e10. Each now grows by 2.0 where the source doubles. src/ast/in_number.rs and the tests beside each reader hold them to their steps. Two more that grew with the square of their input, or faster, now grow with it (#672). A handler swallows a failure when nothing in its body fails the test, and the body of each was walked for its own try and again for every handler around it: the nodes of a tree that fail the test are listed once, each under the function or class that holds it, and a body asks whether one stands between where it begins and where it ends (caught_assertions::Failing, for JavaScript, Java, Kotlin and C#). The pairing of the expected exceptions of one test (expected_exceptions::widened) compared every base expectation with every head expectation, and the rewritten ones again at each step of each search. Which expectation is reported depends on the order of that search, so the pairing is unchanged and reached in less work (src/ast/expected_exceptions/pairing.rs): expectations are sorted into those written alike, two ways of writing one are compared once, a search goes on from where the last search of an expectation written the same stopped, and a run of head expectations a search has no use for is passed at once. The first pairing written is kept beside it in the tests, and the two return the same findings in the same order for 120,000 pairs of lists drawn from a fixed sequence and for lists read from sources of twelve languages. RUN, counted in instructions in a build without optimisation, before and after: handlers 320 deep with nothing that fails in any, 8.0e9 to 9.0e9 and 8.0e8 to 1.1e9; 2,000 expected exceptions each rewritten, 2.0e11 and 2.8e8; each rewritten and wider, 3.2e11 and 4.3e8; each wider in place, 3.3e9 and 3.3e8. Each now grows by 1.9 where the source doubles. A finding a baseline does not hold under its own fingerprint is looked for under the one its line has on its first occurrence, and its file was read again for each such finding: the list of the lines of a file kept for the fingerprints answers this too (baseline::FileLines; through check, 2,000 findings in one file beside a baseline that holds none of them, 7.3e10 and 2.6e10). What still grows with the square of its input (RUN): the rewritten expected exceptions of one test that are written differently from one another, two ways of writing one being compared once for each pair a search brings together, as before (500 blocks that each expect a message of their own, each rewritten: 9.2e9 before and after, and 4.0 times that for twice as many); and two ways of writing one that take head expectations from each other, each search going over the head expectations again (500 expectations of two classes, one a parent of the other: 5.8e11 before, 1.4e9 after, and 4.3 times that for twice as many). The tree-sitter parser itself keeps its stack in memory it allocates: a source of 100,000 nested parentheses parses within its budget on a thread with a 256 kB stack. A part of a file parsed again on its own (source_text::parse_part: a Rust macro’s arguments, a Python skip condition written as a string) is flat in the file’s tree and can nest past the limit where the file does not; a part with no tree is recorded and the pack refuses the whole file (unread_part), so an assertion or a skip nobody read is never counted. A grammar’s lexer reads the character 0 as the end of the file in the middle of a token, so a comment or a string holding a NUL byte ended there and the error region that followed could take the next declaration with it. source_text::parse parses a copy in which each NUL byte is the control character 0x01: the copy is as long as the source, so no byte offset or line moves and node text is read from the source itself. The Swift grammar’s scanner also reads past the end of a string in its directive table when the character 0 follows #if, #elseif, #else or #endif (a file ending in one with no line break, or a NUL byte after one; #621), so swift.rs names the grammar in one function, swift_tree::parse, which takes only a source that ends in a line break (a type with a private field); a file without one is read as the same file with it
src/guards/agent_diff.rs, src/guards/agent_diff/ Semantic diff inspection across base vs. head AST facts. agent_diff.rs runs the gates, reads each side’s facts and holds what the modules share; one module per gate: assertion-reduction (assertion_reduction.rs), vacuous-tests (vacuous_tests.rs), ignored-tests (ignored_tests.rs), unsafe-safety-comment (unsafe_safety_comment.rs), deletion-rationale (deletion_rationale.rs); and one per shared concern: the assertion vocabulary and the runner collection rules of each side (vocabulary.rs), pairing of tests (test_matching.rs) and of test helpers (helper_pairing.rs) across the change, and files that do not parse or gain NUL bytes (parse_errors.rs)
src/guards/hygiene.rs, src/guards/hygiene/ Repository sweeps, one module per gate: time-estimates (time_estimates.rs), pii (pii.rs), agent-scratch (agent_scratch.rs), agents-md (agents_md.rs); hygiene.rs re-exports them and holds what they share
src/guards/integrity.rs, src/guards/integrity/ Structural integrity gates: config-integrity (config_integrity.rs, with the key direction tables in directions.rs and the comparison of two configurations in config_diff.rs) and golden-output (golden_output.rs); integrity.rs re-exports them
src/guards/ci_integrity.rs, src/guards/ci_integrity/ ci-integrity. ci_integrity.rs selects the files, compares each workflow and action file with its base side and holds what the modules share (the verification-step detection, line lookup, the directive check); one module per concern: the rollup job and the documented job count (rollup.rs), triggers and token permissions (triggers.rs), removed workflows, jobs and steps (removed.rs), weakened jobs and steps (job_steps.rs), pairing of steps across a rename (step_matching.rs), GitLab pipelines (gitlab_pipeline.rs), the discipline version a pipeline runs (discipline_pin.rs), action and image pinning (pins.rs) and banned_actions (banned.rs)
src/guards/<gate>.rs One module per remaining gate (shell_secrets.rs, dependency.rs, command.rs, archive_contents.rs, …), with helpers beside their gate: ci_gitlab.rs (ci-integrity), lockfile.rs (dependency-delta), presets.rs (command), archive_formats.rs / archive_presets.rs / source_maps.rs (archive-contents), claim_registry.rs and measured_citations.rs (provenance-tags)
src/guards/config_delta.rs, src/guards/config_delta/ What toolchain-config and sandbox-config share: the rule engine that compares two configuration trees under a rule table (engine.rs), the file classification and format readers (files.rs) and the gate loop both run (gate.rs). Each gate keeps its rule table in a data module (toolchain_config/rules.rs, sandbox_config/rules.rs) and hands the loop what differs: its id, directive, finding kinds and messages, what a file absent on one side means, and what it does with other files
src/guards/perf/ Benchmark regression sentinel (bench-regression): mathematical bounds engine (bounds.rs), harness adapter (mod.rs), the paired-ratio mode (paired_ratio.rs) and override citation freshness (citation.rs)
src/guards/mod.rs Gate execution scheduling, GateOutcome, path filtering, inline marker accounting
src/report/ Multi-format reporting: terminal, GitHub summary, JSON, GitLab Code Quality, JUnit XML, SARIF, agent-prompt
src/docs.rs Automated reference docs generator and schema validation sentinel
src/selftest.rs Embedded positive and negative controls compiled into binary
src/style.rs Zero-dependency ANSI terminal styling
src/forge.rs The in-process HTTPS client for forge REST APIs (reads, and the one write: check --comment), with the path, https, redirect and DISCIPLINE_NO_NETWORK checks
src/doctor.rs, src/doctor/ discipline doctor: the report and local-fact types, the finding codes and the shared file and forge readers (doctor.rs), and one module per concern: workflow analysis (workflows.rs, gitlab_ci.rs), CODEOWNERS (codeowners.rs), single-file findings (local_files.rs), branch-protection reading and judging (protection.rs, protection_findings.rs), the agent-permission, multi-agent and agent-sandbox checks (agents.rs), and the assembly of the report (run.rs)
src/doctor_pins.rs discipline doctor: SHA pins not reachable from a branch or tag of their own repository (imposter commits), and pinned actions whose metadata uses refs that can move (one level deep)
src/doctor_settings.rs discipline doctor: repository settings (Actions policy, default workflow token, immutable releases, tag rulesets or protected tags, secret scoping)
src/override_policy.rs max_overrides and require_approval: whether a run’s directive overrides stand
src/references.rs Issue references in a pull request’s text (#12, owner/repo#12, URLs, closing keywords) and what each resolves to on the forge (issue-link, ratified-paths)
src/ratification.rs ratified-paths: the owner’s Owner-ratified-paths: comment on an issue the pull request closes, read from the forge
src/review_threads.rs review-threads: a pull request’s review threads and whether each is resolved, per forge
src/could_not_check.rs The machine-readable reason a run could not check (exit 2), carried by the JSON report, replay and mcp
src/baseline.rs Grandfathering baseline read / write and fingerprints
src/hook.rs discipline hook run / install: the agent-facing check (base policy, no directives) translated into each agent’s hook contract, and the hook files install writes
src/hook_log.rs The hook’s durable record, <git dir>/discipline/hook-observe.log: one line per change check in either mode and per refused pre-tool call (record, mode, version, decision, exit_code, counts), read by discipline hook log; the report’s hook object, from the agent, event and mode a hook hands its check in DISCIPLINE_HOOK_RUN
src/hookfile.rs What hook install --upgrade needs to keep a local edit: the digest line a generated script, workflow or plugin carries, the merge of this release’s entries into a JSON hook file with content of its own, and the bounded difference printed before anything is overwritten
src/pretool.rs hook run --event pre-tool and --event session-start: refuse an edit into another worktree, a worktree another live session leases, or forbidden_paths before the tool runs; take this worktree’s lease when a session starts. A shell command is parsed with the bash grammar (judge_shell) from an ASCII copy: each byte of a non-ASCII character is replaced by one placeholder byte (0x01, which the grammar reads as part of a word, as it reads the character), so no byte offset moves and every word judged is read from the command itself by its node’s byte range. The grammar’s scanner passes the character it looks at to isdigit in its brace-range scan ({1..3}), which on glibc reads outside a table for a code point above 255 (#618); shell_tree::parse is the only function that names the grammar and it takes only that copy (a type with a private field), so the scanner never sees a character above 127. A here-document whose delimiter has a non-ASCII character is refused: the grammar did not match one before, and two such delimiters of one length are the same placeholders
src/lease.rs discipline lease: per-worktree leases in the common git directory, and the reference-transaction guard that refuses a branch update another live lease claims
src/mcp.rs discipline mcp: the MCP server over stdio (read-only tools)
src/explain.rs discipline explain: a gate’s rule, state, finding codes and lifting directive
src/turn.rs The end-of-turn check of hook run ([hooks.premature-stop], off by default): a final message that announces an action the agent did not take, or ends in a tool call written as text. Not a gate: check never runs it. Its matcher is pinned by tests/fixtures/premature_stop_corpus.json
src/refusals.rs The registry of policy refusals (policy/<code>), which are not findings and have no gate, and their projection into the SARIF, JUnit and GitLab reports
src/findings.rs The registry of finding kinds: each gate/code with its title; a finding that is not registered does not compile
src/replay.rs discipline replay: rebuild merged changes in a throwaway repository and check each
src/comment.rs check --comment: the one pull-request comment, found by marker and edited in place

Agent-facing surfaces. The hook, the MCP server and the agent-prompt format share one design rule: they tell an agent how to repair a finding and leave out the directive that would waive it, and the check they run is judged by the base ref’s configuration and reads no directive, so the change being judged cannot switch off or excuse its own check. The hook’s pre-tool check is the exception: it reads scope-confinement.forbidden_paths from the working tree’s discipline.toml, because it guards where the session writes, not what the change contains; CI still judges the change by the base policy. Hiding the waiver syntax is a convenience (an agent can run discipline explain); the base-side policy and the CI configuration (policy_from: base, PR-body directives, fail_on_overrides, require_approval) are the control.


5. AST Fact Model & Extraction Rules

Fact Representation

Tree-sitter AST extraction translates each source file into one ParsedFileFacts of language-neutral fact structures. A pack declares which facts it fills (LanguagePack::supplies(Fact)); a gate that needs a fact a pack does not supply names the file instead of passing it.

Helper resolution. A call from a test to a function defined in the same file adds that helper’s assertions to the test. C/C++ and Python follow such helpers up to three calls deep (HELPER_DEPTH, transitive_helper in src/ast/mod.rs); the other packs follow one level. Helpers are never followed across files. A function reached only through a dispatch table (a *_DISPATCH spec) is resolved in Rust, JS/TS, Go, Java, Kotlin, C#, C/C++, Ruby, Swift and Scala; Python reads list and tuple dispatch tables in its own extractor; PHP and Objective-C read none.

SAFETY: Invariant Comments

An unsafe block or implementation is documented iff a comment containing SAFETY: is positioned:

  1. In the contiguous run of comments directly preceding the unsafe node or its parent statement; or
  2. Inline between the start of the statement and the unsafe keyword.

Placeholder Rejection: A SAFETY: comment whose words all come from the placeholder list is rejected. The default list (DEFAULT_SAFETY_PLACEHOLDERS in src/config.rs) is todo, tbd, n/a, na, none, safe, safety, unsafe, ok, fine, valid, trust me, trust, me, this, is, totally, replaceable through the gate’s placeholders option. The check is a word filter, not a judgement of the justification: one word outside the list passes (// SAFETY: fixme is accepted by default).

Assertion Strength & Tautologies

Assertions are classified into two levels:

Tautology Filtering: Tautological assertions are deducted from effective assertion counts:


6. Golden-Output Gate Design

Committed test snapshots (e.g. insta .snap), serialized fixtures, and golden files are common drift vectors. Agents frequently re-bless or edit fixtures to make broken tests pass.


7. Reporter Architecture & Fingerprint Hashing

Discipline exports standard structured formats:

Text from outside is neutralised where it is written, once per output format. A report quotes text the binary did not choose and its reader did not write: what a forge answered (a refusal message, a login, a branch name), a pull request’s title and body, a commit subject, a file name, a line of a changed file, the output of a tool a gate ran. Whoever wrote that text must not be able to write the report. The rule is applied at the output, not at each source, so a new source is covered without a change (src/report/text.rs; tests/test_untrusted_text_in_reports.rs delivers hostile text through each source and reads each format):

Output What is done to quoted text
Text report, audit, replay and doctor summaries Every control character but a tab becomes U+FFFD, so the reader sees something was there: C0, C1 and DEL (ESC, CSI, OSC, the bell, backspace, a carriage return), the bidirectional override and isolate controls, the Unicode line and paragraph separators, and invisible characters (zero-width spaces and joiners, directional marks, soft hyphens and tag characters). A note, a title, a location and an override stay one line. A finding’s message and remediation may hold several lines (a quoted tool output): each line after the first is indented under the finding, so none starts where the report’s own lines start
Error message on standard error The same, with the message’s own line breaks kept (a parse error shows an excerpt under it). A forge’s refusal reason is one line before it reaches any message (forge::refusal_message)
Job summary (Markdown) Line breaks become a space and other control characters U+FFFD. The text is read as CommonMark reads it: a backtick run and the next run of the same length are a code span and are written as they are, since nothing in one is active; a run with no partner is escaped. Outside a span &, < and > become entities (no tag, no comment, no autolink), @ becomes &#64; (no mention), ] before ( or [ is escaped (no link, no image), [, *, _ and ~ are escaped (no link text, no emphasis, no strikethrough), and a backslash before any of these is escaped so it cannot undo the escape. A word a renderer would link by itself (one that holds ://, www., mailto: or xmpp:) is written whole as a code span, with a space between it and a code span beside it: GitHub, GitLab, Gitea and Forgejo each link a different set, some after the Markdown is rendered, and none links inside a code span. The one address kept a link is the documentation site’s (https://orieg.github.io/discipline/...), which a message of the binary’s own can name. A word a forge would read as a reference is written as a code span the same way: an issue or pull request (#123, owner/repo#123, GH-123, !123), a commit (a run of seven or more hexadecimal digits standing as a word, so a number of seven digits too), and a mail address (name@host); a reference would notify whoever it names and write a line in the timeline of the issue it points at. A message’s own words pass through the same function, so a number the binary writes inside a message (pull request #12) is a code span as well; the pull request an override was read from, which the report writes itself beside the quoted text, stays a reference. Prefixes a repository configures for its own tracker (TICKET-123) are not known to the binary and are left as they are. In a table cell a pipe is escaped too. A value the report itself sets in a code span (a location, a directive, a base ref) gets a fence one backtick longer than the longest run in it
Pull-request comment (Markdown) As a table cell of the job summary, and a backtick becomes an apostrophe: the only code span quoted text gets is the one written around a word that would be linked. A base ref and a directive’s subject are one code span each, fenced so the text cannot close it. Directive syntax is removed (§7.3)
Workflow annotations (::error ...::) %, a carriage return and a line break are percent-encoded as the runner expects, : and , in a property; other control characters become U+FFFD
agent-prompt A title is one line. A location is one code span on its line: line breaks and runs of white space become one space, other control characters U+FFFD, at most 512 characters, the fence one backtick longer than the longest run in it. A message is inside a fenced block it cannot close, with control characters other than its line breaks replaced, at most 4000 characters. A text that was cut is followed, outside the span or under the block, by [cut: N more characters not shown]. The report’s first lines say that a Location and a fenced block are quoted, to be read as data
Pre-tool hook (hook run --event pre-tool, --event session-start) Every text a refusal or a note quotes is one such code span: the path, the worktree and its directory, the git subcommand, the branch, the agent and session a lease names, and an error. The command a refusal suggests names a branch only when the name is letters, digits and / . _ -; otherwise it shows <branch>. The verdict, the exit code and the JSON shape of each agent’s contract are unchanged
discipline lease (take, check, release, and guard, whose message git prints when it refuses a reference update) The branch, the worktree, and the agent and session a lease names are one such code span each; the suggested lease take --branch command follows the same rule as the hook’s. lease list prints one row a lease: each field on one line, with no control character and at most 512 characters; lease list --json carries the lease as it is stored
MCP server (discipline mcp) check_diff returns the agent-prompt report; an error (a run that could not check, list_gates failing) is a fenced block as above, under a line that says it is quoted. explain_finding and the errors for an unknown tool or method quote the name as one code span. In structuredContent, title and file are one line with no control character and at most 512 characters, and message keeps its lines, with other control characters replaced and at most 4000 characters: a field is data by its place in the JSON
JSON, SARIF, GitLab Code Quality Nothing: the serialiser encodes the text and a consumer reads back what was quoted, byte for byte
JUnit XML &, <, > and both quotes become entities; the characters XML 1.0 forbids (C0 other than tab, line feed and carriage return; U+FFFE and U+FFFF) are dropped
Audit page (HTML) &, <, > and both quotes become entities in content and in attribute values. A character that is not shown or that changes the order of what is shown becomes U+FFFD: the control characters the text report replaces (C0 other than tab, line feed and carriage return, which HTML reads as white space; DEL; C1; the bidirectional embedding, override and isolate controls; U+061C; the line and paragraph separators), and what instruction-smuggling reports as invisible (the zero-width characters and the directional marks U+200B to U+200F, U+2060 to U+2064, U+FEFF, U+180E, the soft hyphen, the tag characters), so a zero-width joiner inside an emoji sequence is shown as U+FFFD too. Every link is built from the repository’s own forge address, with a file’s path percent-encoded, so the link still names the file

In the terminal formats a message with none of these characters is written byte for byte. What is left as it is, on purpose: in Markdown, a # (a forge links #12 to its own issue), and an address of the form name@host.tld, which a renderer may link as a mail address after @ is written as an entity.

7.1 Pure-Rust SHA-256 Implementation & Cryptographic Hygiene

GitLab Code Quality issue tracking and Discipline’s grandfathering baseline engine require unique, deterministic 32-byte hex fingerprints:

7.2 Grandfathering Baseline Architecture

To support brownfield adoption without weakening gates or ignoring violations, Discipline provides a line-number-independent grandfathering baseline:


7.3 Pull-Request Comments (the one forge write)

check --comment posts the report as one pull-request comment and edits it on every later run (src/comment.rs). It is the only write discipline makes to a forge, and it is in the binary rather than in action.yml so every runner gets it: GitHub, Gitea and Forgejo job containers that cannot run uses: actions, and GitLab.

7.4 Review Routing (an inventory, not a gate)

Every check that has a change computes one report section, review_routing (src/routing.rs, #944): what the change touches that a reviewer routes on. It is not a gate: no outcome, no finding code, no severity, nothing to waive, and it never changes the exit code. Its JSON is unstable until 1.0 and none of its names is in tests/fixtures/v1_surface.json; every rendering says so.

7.5 Timings (a measurement, not a verdict)

check --timings (or DISCIPLINE_TIMINGS=1) adds one object to the JSON report, timings (src/timings.rs, #995): wall time per gate and per shared phase (repository, configuration, directives, configured-values, agent-diff, vocabulary, fingerprints, baseline, policy-floor, review-routing), and counters of the work behind each: full extractions by a language pack and the bytes they read, and listings of the changed and the tracked files. Scopes nest: work done in a phase opened inside a gate (the diff extraction five gates share, the runner rules built on first use) is charged to the phase only, so the entries add up to the run. The counters are deterministic for one tree and configuration, which is what tests assert; the times are not, so the object is kept out of CheckSummary and added only as the JSON report is written: no baseline, fingerprint, replay comparison or replay cache key reads it (replay removes DISCIPLINE_TIMINGS from each check it runs). Its JSON is unstable until 1.0, like review_routing. The hook log carries the per-edit wall time on its own, as duration_ms.

8. CI and Release Pipelines

8.1 CI Pipeline (.github/workflows/ci.yml)

Third-party GitHub Actions are pinned by full commit SHA. Tooling binaries (act, actionlint) are installed by version with pinned SHA-256 checksums. Conditional test suites are gated by detect-changes; the ci-gate rollup enforces an allow-list: every required job must succeed or be legitimately skipped, verified at runtime by the ci-skip-set gate, and the total job count (11, the jobs below) is asserted.

Job Verification Scope
detect-changes Evaluates changed paths on pull requests to output skip/run decisions for downstream heavy test jobs; runs unconditionally and triggers all suites on push events.
lint cargo fmt --check, cargo clippy --all-targets --locked -- -D warnings, actionlint on .github/, .gitea/ and .forgejo/ workflows, shellcheck, lint-action.py (F11), docs --check (generated reference in sync), link and CI-recipe validator (check-links.py), ecosystem theme contract, release-notes sanitiser and ledger checks, change detector tests (detect_changes.py --test), test-check-major-tag.sh, test-tag-release.sh, and a PR-title mention lint.
test (Linux & macOS) Full test suite execution asserting at least 65 test cases ran, followed by embedded self-test.
msrv cargo check under the pinned Minimum Supported Rust Version (1.90).
supply-chain cargo-deny validation of advisories, bans, license allow-list, and sources.
build-static Builds the x86_64-unknown-linux-musl binary every downstream job uses; validates static linkage and executes self-test. (aarch64 musl is built by the release pipeline.)
action-github Exercises composite action on a hosted Linux runner: clean fixture passes, bad fixture fails for expected gates, overrides take effect, unresolvable base exits 2, tampered archive rejected.
action-gitea Runs .gitea/workflows/action-selftest.yml under act using Gitea runner images with mandatory log assertions, then runs the documented job-container recipe under act against an image built from this run’s binary. (.forgejo/workflows/action-selftest.yml is linted by actionlint only.)
pre-commit Runs pre-commit try-repo against staged fixtures.
dogfood Executes Discipline against its own repository diff (policy_from: base), then discipline doctor (local checks under --strict, forge checks without it).
audit The cumulative view no single pull request’s check has (#933): discipline audit --last 50 of this repository’s history into the step summary, then tests/action/test-audit-canary.sh, an inverted canary. In a scratch repository it seeds a strict base with recorded assertion counts, a loosening pushed with a directive and no pull request, and a later commit gutting a test; it asserts that check on the gutting change reports test-floor/test-density-regression while assertion-reduction (switched off) does not, that audit names loosened-without-pull-request and finds assertion-density, and that restoring both clears them. Then times replay --last 5 with the release binary twice and asserts the second run read every verdict the first reached from the replay cache (the step summary carries both times). Also runs weekly on a schedule.
docker-smoke Builds the container image from the static binary and runs it against a root-owned workspace (positive control), a bad fixture (negative control) and an in-container checkout.

8.2 Release Pipeline (.github/workflows/release.yml)

How a release is cut is under Cutting a release; the repository settings the pipeline relies on are in §8.4.

Releases are triggered exclusively by pushing a vX.Y.Z tag:

  1. Verify: Asserts tag matches Cargo.toml version, tagged commit resides on main, tests/lints/deny pass, and generates a CycloneDX SBOM (discipline.cdx.json) under --locked semantics using pinned cargo-cyclonedx (0.5.9).
  2. Build: Compiles 4 release targets (x86_64-musl and aarch64-musl, asserted static; x86_64-darwin, aarch64-darwin) and executes self-test on each target that can run on its runner: x86_64-darwin is cross-built on Apple Silicon, and when it cannot execute there its self-test is skipped with a notice. The musl legs also build the .deb and .rpm packages, which are checksummed into SHA256SUMS, attested and uploaded with the archives.
  3. Publish: Generates SHA256SUMS (covering the archives, packages, and discipline.cdx.json), the Homebrew formula and the MacPorts Portfile (with the tag’s source-archive and crate checksums), attaches build-provenance attestations (one attestation for the archives and packages, also attached to the release as discipline.intoto.jsonl for offline gh attestation verify --bundle) and an SBOM attestation via actions/attest against the archives, creates the GitHub release (not yet latest), uploads release assets including discipline.cdx.json, reads its assets back and checks them against SHA256SUMS, and pushes the container image under its exact tags (0.7.0, v0.7.0) only, with a provenance attestation stored in the registry. Release binaries are built with the toolchain pinned in RELEASE_TOOLCHAIN, and the image from an Alpine base pinned by digest.
  4. Smoke test: Action downloads published release assets on Linux and macOS, validates checksums, tests clean and negative fixtures, and verifies each archive’s GitHub attestation (signed by release.yml, for the tag).
  5. Promote: Only after every smoke test succeeds: verifies the image’s provenance, marks the release latest, re-tags the proven image manifest as v0, 0, v0.7, 0.7 and latest (no rebuild), publishes the crate to crates.io via OIDC trusted publishing, and updates the Homebrew tap.
  6. Move major tag: Advances floating major version tag (v0) last. A workflow using @v0 runs the binary of the version in that tag’s Cargo.toml, never latest, so the action code and the binary always come from the same release.
  7. Post-release guard: Verifies via tests/action/check-major-tag.sh that the major tag dereferences to the release commit.

Supply-chain controls around these steps:

Cutting a release

Every release follows a release candidate of the same version (next section) that passed the release pipeline on the same code. scripts/tag-release.sh refuses a stable tag without one (#925): v0.28.0 was tagged without a candidate, failed in verify on an advisory published after main’s CI had passed, and spent its version.

  1. Bump PR (chore(release): bump version to X.Y.Z with its ledger rows): Cargo.toml, discipline’s entry in Cargo.lock, and every install instruction that names a release (the version-lockstep release group in discipline.toml lists them: README, docs, templates, man pages, .pre-commit-hooks.yaml, CITATION.cff’s version). Set CITATION.cff’s date-released by hand; nothing checks it. Relabel the unreleased rows of both ledger tables in docs/ROADMAP.md to vX.Y.Z (they become the notes’ “Upgrading” section), then discipline docs --write. Leave this repository’s own agent-hook files alone (.claude/hooks/discipline-bootstrap.sh, .github/workflows/copilot-setup-steps.yml), and the orieg/discipline@<sha> # vX.Y.Z pins of the CI examples in README.md, docs/index.html and docs/guides/ci-platforms.md: they pin a published release, whose commit the bump cannot know, and move to the new one in a follow-up once it is out. test_adoption::readme_ci_examples_pass_the_pull_request_that_adds_them fails while the README’s pin and copilot-setup-steps.yml’s differ.
  2. Replay before tagging: run discipline replay over the recent history of downstream repositories under the current release and under the candidate, each with that repository’s own discipline.toml, and report every changed verdict or newly blocking gate before the tag. The candidate is a new binary, so none of its verdicts come from the replay cache: every change is rebuilt and checked, at the cost the README states for replay --last 50. A change whose merged pull request cannot be read (no token, or offline) is could_not_check and says nothing about the candidate.
  3. Tag: scripts/tag-release.sh X.Y.Z --commit <sha> --push, with <sha> the bump’s commit on main (a squash merge, so not a merge commit; without --commit it takes origin/main as just fetched, never the shell’s HEAD). Before it creates the annotated, signed tag it checks that the commit is on origin/main, that its Cargo.toml and Cargo.lock carry X.Y.Z, that its CI run passed and that the tag exists neither locally nor on origin. For a stable X.Y.Z it also checks the newest candidate vX.Y.Z-rc.N on origin: its Release run completed with success on the candidate’s commit, that commit is an ancestor of <sha>, and between the two only release metadata changed (discipline’s own version in Cargo.toml and both Cargo.lock files, CITATION.cff, README.md, .pre-commit-hooks.yaml, docs/, man/, templates/). Then it creates the tag, reads it back, and pushes only when it dereferences to that commit (tests/action/test-tag-release.sh). Without --push it prints the push command. A pushed tag cannot be moved or deleted (step 6), so a wrong tag costs the version. The pipeline above does the rest, including latest, the floating image tags, crates.io publication, the Homebrew tap, the package repositories and v0.
  4. Release notes come from GitHub’s generator, given only the new tag: it compares against the previous published release and skips drafts (measured with the v0.15.0-rc.2 draft present: the range for v0.15.0 was v0.14.4...v0.15.0), so release candidates do not shorten a release’s notes. The ledger rows labelled with the version are prepended as “Upgrading”.
  5. After: Zenodo archives the published release (see §8.4). After the first archived release, a follow-up records the concept and version DOIs in CITATION.cff and the README badge.
  6. A failed release is not re-run from a moved tag: the tag cannot move (release tags ruleset), and a re-run uses the workflow as it was at the tag. Fix main and release the next patch version, after its own candidate. The required candidate finds pipeline failures without spending a version; an advisory published between the candidate and the release can still fail verify, so tag the release soon after its candidate passes.

Release candidates

A release candidate (vX.Y.Z-rc.N) is required before every release. It runs the same pipeline as a release, up to smoke, and stays a draft GitHub release. Nothing about it is public except the container image under its exact tags (X.Y.Z-rc.N, vX.Y.Z-rc.N); no floating reference moves.

  1. Bump PR: Cargo.toml to X.Y.Z-rc.N, Cargo.lock, and discipline docs --write (the man1 page). Install instructions (README, docs, templates, man5, .pre-commit-hooks.yaml) and CITATION.cff keep the latest stable release, so no user is pointed at a candidate; the commit body carries allow-version-mismatch: release <reason>. tests/action/check-links.py and tests/test_editor_integrations.rs read the stable version from CITATION.cff while Cargo.toml carries a candidate. Ledger rows stay unreleased, so the candidate’s notes have no “Upgrading” section.
  2. Tag: scripts/tag-release.sh X.Y.Z-rc.N --commit <sha> --push, with <sha> the bump’s commit on main. verify checks the tag against Cargo.toml and main.
  3. What runs: verify, build, publish and smoke; promote and move-major-tag are skipped. publish creates the draft, uploads every asset, reads them back against SHA256SUMS, and leaves the release a draft. A draft’s assets are not public, so smoke serves this run’s archives and SHA256SUMS to the action on 127.0.0.1 and verifies each archive’s attestation directly.
  4. Zenodo needs no action. Zenodo archives every published release, pre-releases included, and skips only drafts: its GitHub receiver reads the release’s draft flag, never prerelease. A candidate is never published, so it never gets a record or a DOI. Do not publish a candidate’s draft by hand.
  5. A failed candidate is replaced, not re-run. A re-run executes the workflow as it was at the tag, and the tag cannot move (release tags ruleset). Fix main, bump to X.Y.Z-rc.N+1, and tag again. The failed candidate’s draft can be deleted: it was never public.
  6. The release is the ordinary bump to X.Y.Z, which moves every install instruction, CITATION.cff and the ledger rows. Nothing else may change between the candidate and the release: a fix to code, tests, workflows, scripts or a dependency after the candidate needs a new candidate, X.Y.Z-rc.N+1, first.

Packages: a candidate’s .deb and .rpm carry the version as X.Y.Z~rc.N (- is not allowed in an RPM version and is the Debian revision separator; ~ sorts before X.Y.Z), under file names that keep -, since GitHub renames release assets whose names carry ~.

8.2.1 Documentation Site and Package Repositories (.github/workflows/pages.yml)

The site is built from docs/ and deployed through GitHub Pages’ Actions source. The APT and RPM repositories are assembled at deploy time from the latest stable release’s .deb and .rpm assets (checked against its SHA256SUMS) and signed with the REPO_SIGNING_KEY secret: an APT InRelease / Release.gpg and an RPM repomd.xml.asc, with the public keys published as apt/discipline-archive-keyring.gpg and rpm/RPM-GPG-KEY-discipline. No package or repository metadata is committed. Without the key the workflow fails rather than publish an unsigned repository. It runs on every docs change and after each release (dispatched by promote). Three jobs: site renders docs/ with the Pages actions and holds no secret; sign downloads the packages, adds the signed repositories and is the only job that reads REPO_SIGNING_KEY (a secret of the package-signing environment), running no third-party action and with egress limited to GitHub; deploy publishes.

Signing Key Maintenance

One OpenPGP key signs the APT InRelease / Release.gpg and the RPM repomd.xml.asc. Its private half is held in two places only: the package-signing environment secrets (REPO_SIGNING_KEY, passphrase-protected, and REPO_SIGNING_PASSPHRASE), which GitHub never returns, and the maintainer’s copy, of which an encrypted offline backup (private key, passphrase and revocation certificate) is kept. The public half is not committed: sign exports it on every deploy to apt/discipline-archive-keyring.gpg and rpm/RPM-GPG-KEY-discipline.

The key carries an expiry date. Read it from what users have installed:

curl -fsSL https://orieg.github.io/discipline/apt/discipline-archive-keyring.gpg | gpg --show-keys

Extending it, well before that date, keeps the same key, so no user imports a new one; they only refresh their copy:

  1. On the maintainer’s copy: gpg --quick-set-expire <fingerprint> <new-expiry>.
  2. Replace REPO_SIGNING_KEY in the package-signing environment with the armoured export of the extended key (gpg --armor --export-secret-keys <fingerprint>, piped to gh secret set REPO_SIGNING_KEY --env package-signing; never written to a file in the repository). The passphrase does not change.
  3. Run pages.yml (gh workflow run pages.yml --ref main). sign republishes the public key with the new date.
  4. Check with the curl … | gpg --show-keys line above that the published key shows the new expiry.
  5. Refresh the offline backup.
  6. Tell users in the next release notes to refresh the key: APT users re-run the keyring curl line of the install instructions (the file under /usr/share/keyrings/ is written once and never refreshed, so apt update fails with an expired-key error after the old date); RPM users re-import RPM-GPG-KEY-discipline.

Replacing it (the private key is lost, or its expiry cannot be extended): generate a new passphrase-protected key, set both package-signing secrets, run pages.yml, and tell users to import the new public key the same way; until they do, their package manager refuses the repository. If it is compromised, publish its revocation certificate as well: import it into the key before exporting the public half, so the published keyring shows the old key revoked, then replace the key.

Major Tag Floating Pointer Invariant

Major tags (v0, v1) provide consumer convenience for action workflows (uses: orieg/discipline@v0). The release workflow contract mandates that major tags are moved exclusively by the release pipeline (release.yml) after all smoke tests pass against published release assets. Moving floating major tags manually or out-of-band bypasses compilation, static linkage verification, attestation generation, and smoke tests, which defeats the security guarantees of the sentinel. To prevent silent tag drift, two automated sentinels enforce this invariant:

8.3 CodeQL Security Pipeline (.github/workflows/codeql.yml)

Static security analysis runs on pull requests, pushes to main, and on a scheduled run (cron: '30 6 * * 1') using GitHub CodeQL Advanced setup (github/codeql-action pinned by SHA):

CodeQL Rust Build Mode

Advisory Security Sentinel Contract & Triage Procedures

8.4 Repository Settings the Pipelines Rely On

These live in the forge, not in the tree, so a review of the workflows alone does not show them. Check them with the gh api calls named in each row; discipline doctor reads branch protection, the Actions policy, the default workflow token, immutable releases, the tag rulesets and whether a repository-level secret is read only from environment-bound jobs (the admin-only settings need an admin token; under the workflow’s read token they are reported as not visible). It also checks each SHA-pinned action against its own repository (imposter-commit) and reads its metadata at that commit for refs that can move (nested-action-pins).

Setting Value Why Read with
Immutable releases on A published release’s tag and assets cannot be replaced: a consumer pinned to a version keeps getting the bytes that were attested. Applies to releases published after it was turned on. gh api repos/orieg/discipline/immutable-releases
Actions policy allowed_actions: selected, sha_pinning_required: true, GitHub-owned actions plus an explicit allow-list A workflow cannot run an action outside the list, or any action by tag. Adding a third-party action to a workflow means adding it to the allow-list first, or the job fails at start. A uses: docker:// step is checked against the list too and does not match a docker:// pattern, so a container image is run from a run: step (docker run <image>@sha256:…), as pages.yml does for the Jekyll build. .../actions/permissions, .../actions/permissions/selected-actions
Default workflow token read; cannot approve pull requests Write scopes are granted per job. .../actions/permissions/workflow
Ruleset main protection required signed commits, required status check ci-gate, no force-push, no deletion, changes through pull requests Only a green, signed pull request reaches main. A pull request is rebased locally (a server-side rebase drops the signatures) and squash-merged. .../rulesets
Ruleset release tags refs/tags/v*.*.*: no update, no deletion, no bypass A release tag, candidate tags included, names one commit forever (though an administrator can edit the ruleset for an emergency bypass; v0.17.1 was deleted and re-created at another commit through a temporary ruleset bypass). .../rulesets
Ruleset major tags refs/tags/v0, refs/tags/v1: no update, no deletion; bypass: deploy keys Only move-major-tag, pushing with MAJOR_TAG_DEPLOY_KEY, moves a floating tag. (The GitHub Actions app cannot be a bypass actor on a user-owned repository, hence the deploy key.) .../rulesets
Environment release deployment policy: tags v*.*.*; secrets HOMEBREW_TAP_DEPLOY_KEY, MAJOR_TAG_DEPLOY_KEY Read by promote and move-major-tag only, and only from a release tag. .../environments/release, gh secret list --env release
Environment package-signing deployment policy: branch main; secrets REPO_SIGNING_KEY (passphrase-protected, armoured), REPO_SIGNING_PASSPHRASE Read by pages.yml sign only. The key’s public halves are published with the APT and RPM repositories, so replacing the key breaks every installed source until users fetch the new key; extend its expiry instead. .../environments/package-signing
Environment github-pages branches main, gh-pages pages.yml deploy. Pages source: GitHub Actions. .../pages
Repository secrets none Every secret is scoped to the environment of the one job that reads it. gh secret list
Deploy keys this repository: release: move major tag (write); orieg/homebrew-tap: discipline release (release environment) (write) The private halves exist only as the release environment secrets above. Rotating one: add a new key, set the secret, delete the old key. gh repo deploy-key list
Zenodo integration webhook on release events Every published release is archived with a DOI; drafts are skipped (its receiver reads draft, never prerelease), which is why candidates stay drafts. .../hooks
ratified-paths protected_paths in discipline.toml (workflows, action.yml, discipline.toml, agent guides and hooks); ratifier orieg A pull request that edits a protected path passes dogfood only when an issue it closes carries an unedited comment by the ratifier naming the path (Owner-ratified-paths: block, see docs/GATES.md). A comment posted after dogfood ran needs a re-run of that job. discipline.toml
Auto-merge allowed Squash auto-merge pinned to the head that went green (gh pr merge --squash --auto --match-head-commit <sha>). gh api repos/orieg/discipline

8.5 OpenSSF Scorecard (.github/workflows/scorecard.yml)

OpenSSF Scorecard scores the repository’s supply-chain practices from outside: a measurement this project does not grade itself, and a cross-check of the doctor mapping in docs/guides/ci-platforms.md (where the two disagree, one of them is wrong).

8.6 Fuzzing (.github/workflows/fuzz.yml)

Scorecard’s Fuzzing check looks for a fuzzing integration; for Rust it is any *.rs file that names libfuzzer_sys (cargo-fuzz). The fuzz/ crate is that integration, and fuzz.yml runs it, so the targets are exercised and not only present. The threat model is fail-closed (§3): input that cannot be read is an Err or a named “not analysed”, never a panic, a hang or an unbounded allocation.

Target Code under test Input
language_packs every pack’s extract (Rust, Python, JS/TS, Java, Go, PHP, C/C++, C#, Ruby, Kotlin, Swift, Scala, Objective-C, PHPT) and ast::analyze arbitrary bytes as source; the first byte picks the pack, a test or non-test path and a custom assertion vocabulary
config_toml DisciplineConfig::from_toml_str arbitrary bytes as discipline.toml
directives tokens directive parsing, reason validation and citation extraction arbitrary bytes as a pull-request body or commit message
pr_text references::parse (closing keywords, four forges), ratification::parse_blocks and refuse_entry arbitrary bytes as a pull-request body or an owner’s comment
pretool_payload pretool::parse and parse_session_start (all agent shapes), patch_targets, judge_shell (tree-sitter-bash) arbitrary bytes as a hook payload or shell command
archive_walk archive_formats::detect and walk (zip, tar and its compressions, gem, deb, rpm) arbitrary bytes written to a file whose name the first byte picks
artifact_parsers perf::parse_metrics (Criterion, Google Benchmark, pytest-benchmark, go test -bench, iai-callgrind, neutral JSON), source_maps arbitrary bytes as a benchmark artifact or source map
yaml_and_lockfiles lockfile::parse_lock and diff_lock (pnpm-lock.yaml, yarn.lock, Cargo.lock, package-lock.json, poetry.lock, uv.lock, composer.lock, Gemfile.lock), ci_gitlab::includes and diff_gitlab_ci, citation_metadata::check arbitrary bytes as a lockfile, GitLab CI pipeline or citation record
forge_and_smuggling forge::parse_remote, ssh_hostname, clean_url, encode_segment, check_api_path, and instruction_smuggling::invisible_classes, phrase_classes, fold, has_mixed_script_word, has_encoded_blob, text_classes arbitrary bytes as a remote URL, SSH config, API path, or text scanned for smuggling

The two targets that reach a tree-sitter parser (language_packs, pretool_payload) run their body inside deep_stack::on_deep_stack, as the binary runs its work: an input of the job’s 64 KiB can nest to the tree depth limit, which a walker descends on that stack and not on the fuzzer’s own (#667).

There is no unified-diff parser in the tree to fuzz: the diff is read from the object database through libgit2 (gitctx), so the targets cover what is parsed from text instead. A target fails on a panic, abort, out-of-memory or timeout; an Err is a pass.

8.7 Mutation Testing (.github/workflows/mutants.yml)

AGENTS.md §3.4 asks each gate’s pull request for mutation evidence: one mutant, and the test that kills it. That is checked once, when the gate lands; nothing checks later that a refactor left every detector branch covered. This workflow does: cargo-mutants changes each function, operator and match arm in src/ast/ and src/guards/ in turn and runs the unit tests, and a mutant no test fails on is a survivor. The command gate’s cargo-mutants preset is scoped to a pull request’s diff (--in-diff); this is the sweep of the repository’s own detectors, one slice of it per run.


9. Test Discipline for Gates

Every gate implemented in Discipline must satisfy the 4-point testing contract before shipping:

  1. Unit tests: Detector-level unit tests with both positive and negative controls (src/** #[cfg(test)]).
  2. End-to-end binary tests: Real binary executions driving throwaway git repositories and parsing JSON outputs (tests/test_gates_e2e.rs).
  3. Mutation evidence: Detectors must be deliberately inverted or broken, with proof that the test suite fails on the mutant. After a detector ships, the scheduled mutation run (§8.7) re-checks the detectors in src/ast/ and src/guards/, one slice of the files per run and every changed detector in the next run, and fails when a file has more surviving mutants than its ratchet allows.
  4. Self-test cases: Compiled directly into the binary (src/selftest.rs) to allow deployed binaries to verify their own discriminators.

Evasion cases, the shapes a change takes to weaken a test or the run without tripping a gate, are recorded with their verdict in tests/fixtures/evasion_corpus.json and driven through the binary by tests/test_evasion_corpus.rs: caught with the exact finding codes, or known_miss with the issue that tracks it. A caught case that stops being caught fails the suite, and so does a known miss that starts being caught, until its verdict and the gate’s “What it does NOT catch” list in docs/GATES.md are updated together. A new evasion shape goes there first, as a known miss, before the detector that closes it.

Threat model claims, adversarial attack probes, and isolated container reproduction environments are documented under tests/red_team/README.md.


10. Known Limits