Skip to content

Commit ef5c39a

Browse files
committed
Add architecture drift ratchets
1 parent bf00883 commit ef5c39a

7 files changed

Lines changed: 244 additions & 74 deletions

File tree

.github/copilot-instructions.md

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,16 @@ Self-hosted security gateway between AI agents and the rest of the world. Multi-
1010

1111
1. **Correctness** — does the code do what its name/comments/tests claim?
1212
2. **Security** — secret leakage in logs, missing auth/identity check, substitution-boundary regression, exfil paths
13-
3. **Resource & error handling** — see Rust path-scoped instructions
14-
4. **Performance** — only when measurable in a hot path; otherwise skip
15-
5. **Style** — skip entirely (pre-commit handles it)
13+
3. **Architecture drift** — new source of truth, boundary bypass, god-object growth, stringly core logic, background state without ownership
14+
4. **Resource & error handling** — see Rust path-scoped instructions
15+
5. **Performance** — only when measurable in a hot path; otherwise skip
16+
6. **Style** — skip entirely (pre-commit handles it)
1617

1718
## Tools that already gate — don't re-flag what they catch
1819

1920
- `cargo fmt --all -- --check` — formatting (pre-commit blocks commit)
2021
- `cargo clippy --all-targets -- -D warnings` — lints (pre-commit + CI)
22+
- `ruby scripts/check-architecture-ratchets.rb` — large-module budgets and watched pattern counts. Do not repeat the raw line-budget failure as a review comment unless the diff shows the architectural reason it grew or an obvious split point.
2123
- `gitleaks protect --staged` — secret scanning (pre-commit) + the same scan in CI. `.gitleaks.toml` allowlists by **path** (`tests/**/fixtures/`, `docs/rfcs/*.md`, lockfiles, `crates/paste-server/src/lib.rs`, `*.example.*`) and by **regex** (loopback IPs, RFC 5737 doc-ranges, a few specific inherited-from-main values). If a finding is inside an allowlisted path or matches an allowlisted regex, do NOT re-flag it — it's intentional. Findings outside the allowlist are real.
2224

2325
## Project context — NOT bugs despite looking like them
@@ -28,6 +30,19 @@ Self-hosted security gateway between AI agents and the rest of the world. Multi-
2830
- References to `zeroclaw_*` (no trailing `ed`) are the upstream third-party tool we wrap, NOT pre-rename leftover of this project.
2931
- Mixed Rust edition (2021 + 2024) is known and tracked. Do NOT suggest the bump unless the PR is explicitly about edition migration.
3032

33+
## Architecture drift worth flagging
34+
35+
Only flag these when the diff gives concrete evidence and a local fix or narrower design question is available:
36+
37+
1. **Growing an existing oversized module** — especially `commands.rs`, channel modules, installer executor, proxy handlers, config, or doctor — when the added behavior could live behind a typed helper/module boundary.
38+
2. **New duplicate source of truth** — adapter kinds, model identifiers, channel capabilities, secret policy, gateway routing, install paths, or lifecycle state copied into a second registry/table without a synchronization plan.
39+
3. **Stringly core decisions** — security, routing, model selection, adapter lifecycle, approval, or persistence logic operating directly on raw `String`, `Vec<String>`, positional args, or `HashMap<String, String>` after the external boundary has already been crossed.
40+
4. **Background work without ownership** — spawned tasks that mutate shared state, swallow errors, or outlive the request/channel/session without a cancellation and reporting path.
41+
5. **Gateway/proxy bypasses** — new provider, fetch, exec, browser, or agent network paths that avoid Calciforge's configured model gateway/security proxy without being explicitly documented as opt-out or unenforceable.
42+
6. **Config/docs/test drift** — new channel, adapter, model gateway, or security config fields without matching docs and compile/smoke coverage.
43+
44+
Do not ask for a broad rewrite. Prefer comments like: "This adds another command sub-flow to `commands.rs`; can this live in `commands/<domain>.rs` with a typed request enum so the ratchet budget does not keep rising?"
45+
3146
## Self-discipline
3247

3348
- Do NOT repeat a comment already made on a parent or sibling PR in the same stack. If the same observation was raised on PR #N and merged/addressed, do not re-raise on PR #N+1. Past noisy patterns: the dead-doc-reference comment was posted four times across PRs #20/#23/#25; the env-mutex/`serial_test` comment was posted eight+ times across #19/#22/#23.

.github/instructions/rust.instructions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ These extend `.github/copilot-instructions.md`. Same review philosophy: **if unc
2525
- `tokio::select!` branches must be cancellation-safe. If a branch holds partial state across `.await` (e.g., a half-written buffer), losing the race silently corrupts state. Worth flagging if non-obvious.
2626
- `tokio::process::Command` without `.kill_on_drop(true)` leaks the child if the parent task is dropped mid-await. Flag for long-running children; skip for one-shot commands that are awaited to completion.
2727
- Spawned `JoinHandle`s that are dropped silently swallow panics. Worth flagging for long-running tasks; not for fire-and-forget helpers.
28+
- New `tokio::spawn` or thread-spawned work that mutates shared state should have an obvious owner, cancellation/error reporting path, and ordering story. Flag detached background state changes that make request/session/channel lifecycle implicit.
29+
30+
## Boundary hygiene
31+
32+
- Raw config/protocol/CLI values should be converted into typed structs or enums before security, routing, lifecycle, persistence, or model-selection decisions. Flag new core logic that keeps branching on arbitrary strings or positional `Vec<String>` indexes when a local typed request/decision type would make invalid states unrepresentable.
33+
- New registries for adapter kinds, channel capabilities, model identifiers, or secret policy should reuse the existing source of truth. Flag duplicated tables unless the diff includes a synchronization comment/test.
2834

2935
## Lints / attributes
3036

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
- name: Check installer config helpers
3434
run: python3 scripts/test-upsert-calciforge-agent.py
3535

36+
- name: Check architecture ratchets
37+
run: ruby scripts/check-architecture-ratchets.rb
38+
3639
# ─────────────────────────────────────────────────────────────────────────────
3740
# Check formatting and linting
3841
# ─────────────────────────────────────────────────────────────────────────────

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ User-facing tour: `README.md` → [calciforge.org](https://calciforge.org/).
5050
10. **Product-contract check before design changes.** Before changing installer behavior, gateway routing, secret handling, agent adapters, channel UX, or model selection, stop and ask: does this API or behavior actually fulfill Calciforge's design intent as a self-hosted security gateway, or merely make the local code pass? Preserve central promises such as one operator-owned secret store, agents never receiving plaintext secrets by default, model traffic flowing through the configured gateway unless explicitly opted out, and channel commands behaving consistently across supported transports.
5151
11. **Cross-node assumptions must be explicit.** Do not assume a helper binary, config file, fnox vault, MCP server, or environment variable exists on an agent host just because it exists on the Calciforge host. Multi-node features need an explicit propagation model, a runtime smoke test from the agent host, and docs that name whether state is central or local.
5252
12. **Avoid accidental architecture drift.** If a quick fix creates a second source of truth, bypasses the gateway/proxy, weakens a security boundary, or contradicts a documented roadmap/ADR, treat that as a design bug. Either implement the coherent version or leave a clearly documented follow-up with the user-visible limitation.
53+
13. **Large files are debt with budgets, not precedent.** `scripts/check-architecture-ratchets.rb` pins current oversized Rust modules to explicit line budgets and fails CI if they grow. New Rust modules should stay under the default budget unless the PR explains the boundary being created and adds a budget consciously.
54+
14. **Stringly data stays at the boundary.** It is acceptable for config, JSON, CLI args, and protocol payloads to enter as `String`, `Vec<String>`, or `HashMap<String, String>`, but core logic should convert them into typed structs/enums before making security, routing, lifecycle, or persistence decisions.
55+
15. **Detached work needs an owner.** New `tokio::spawn` or thread-spawned work must have an explicit lifecycle owner, cancellation/error path, and state handoff. Do not update shared mutable state from background tasks unless the owning module documents the ordering and failure behavior.
5356

5457
## Build / test
5558

crates/calciforge/src/commands.rs

Lines changed: 4 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ use crate::messages::{ChoiceControl, ChoiceOption, Match, OutboundMessage};
3434
use crate::model_names::configured_first_class_model_ids;
3535
use crate::providers::alloy::AlloyManager;
3636

37+
mod parser;
38+
39+
use parser::{command_suggestion, command_token, first_arg, second_arg};
40+
3741
const PENDING_CHOICE_TTL: Duration = Duration::from_secs(10 * 60);
3842

3943
/// Default state directory: `~/.config/calciforge/state/`.
@@ -94,18 +98,6 @@ fn session_runtime_readiness_error(agent_cfg: &crate::config::AgentConfig) -> Op
9498
}
9599
}
96100

97-
fn first_arg(text: &str) -> Option<&str> {
98-
text.split_whitespace().nth(1)
99-
}
100-
101-
fn second_arg(text: &str) -> Option<&str> {
102-
text.split_whitespace().nth(2)
103-
}
104-
105-
fn command_token(text: &str) -> &str {
106-
text.split_whitespace().next().unwrap_or("")
107-
}
108-
109101
fn gateway_model_selector_ids(config: &CalciforgeConfig) -> HashSet<String> {
110102
configured_first_class_model_ids(config)
111103
.into_iter()
@@ -160,65 +152,6 @@ impl fmt::Display for AgentChoiceError {
160152
}
161153
}
162154

163-
fn command_suggestion(cmd: &str) -> Option<&'static str> {
164-
const MAX_FUZZY_COMMAND_CHARS: usize = 64;
165-
const COMMANDS: &[&str] = &[
166-
"!help",
167-
"!status",
168-
"!agents",
169-
"!agent",
170-
"!sessions",
171-
"!session",
172-
"!new",
173-
"!btw",
174-
"!gateway",
175-
"!metrics",
176-
"!ping",
177-
"!switch",
178-
"!default",
179-
"!model",
180-
"!secure",
181-
"!secret",
182-
"!approve",
183-
"!deny",
184-
];
185-
186-
let lower = cmd.to_lowercase();
187-
let without_bang = lower.trim_start_matches('!');
188-
if without_bang.chars().count() > MAX_FUZZY_COMMAND_CHARS {
189-
return None;
190-
}
191-
192-
COMMANDS
193-
.iter()
194-
.copied()
195-
.find(|candidate| candidate.trim_start_matches('!') == without_bang)
196-
.or_else(|| {
197-
COMMANDS.iter().copied().find(|candidate| {
198-
levenshtein_distance(without_bang, candidate.trim_start_matches('!')) <= 2
199-
})
200-
})
201-
}
202-
203-
fn levenshtein_distance(a: &str, b: &str) -> usize {
204-
let b_len = b.chars().count();
205-
let mut costs: Vec<usize> = (0..=b_len).collect();
206-
207-
for (i, ca) in a.chars().enumerate() {
208-
let mut previous = costs[0];
209-
costs[0] = i + 1;
210-
for (j, cb) in b.chars().enumerate() {
211-
let insertion = costs[j + 1] + 1;
212-
let deletion = costs[j] + 1;
213-
let substitution = previous + usize::from(ca != cb);
214-
previous = costs[j + 1];
215-
costs[j + 1] = insertion.min(deletion).min(substitution);
216-
}
217-
}
218-
219-
costs[b_len]
220-
}
221-
222155
/// Load persisted active-agent selections from a given state directory.
223156
/// Returns an empty map if the file doesn't exist or can't be parsed.
224157
fn load_active_agents_from(state_dir: &Path) -> HashMap<String, String> {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const MAX_FUZZY_COMMAND_CHARS: usize = 64;
2+
3+
const COMMANDS: &[&str] = &[
4+
"!help",
5+
"!status",
6+
"!agents",
7+
"!agent",
8+
"!sessions",
9+
"!session",
10+
"!new",
11+
"!btw",
12+
"!gateway",
13+
"!metrics",
14+
"!ping",
15+
"!switch",
16+
"!default",
17+
"!model",
18+
"!secure",
19+
"!secret",
20+
"!approve",
21+
"!deny",
22+
];
23+
24+
pub(super) fn first_arg(text: &str) -> Option<&str> {
25+
text.split_whitespace().nth(1)
26+
}
27+
28+
pub(super) fn second_arg(text: &str) -> Option<&str> {
29+
text.split_whitespace().nth(2)
30+
}
31+
32+
pub(super) fn command_token(text: &str) -> &str {
33+
text.split_whitespace().next().unwrap_or("")
34+
}
35+
36+
pub(super) fn command_suggestion(cmd: &str) -> Option<&'static str> {
37+
let lower = cmd.to_lowercase();
38+
let without_bang = lower.trim_start_matches('!');
39+
if without_bang.chars().count() > MAX_FUZZY_COMMAND_CHARS {
40+
return None;
41+
}
42+
43+
COMMANDS
44+
.iter()
45+
.copied()
46+
.find(|candidate| candidate.trim_start_matches('!') == without_bang)
47+
.or_else(|| {
48+
COMMANDS.iter().copied().find(|candidate| {
49+
levenshtein_distance(without_bang, candidate.trim_start_matches('!')) <= 2
50+
})
51+
})
52+
}
53+
54+
fn levenshtein_distance(a: &str, b: &str) -> usize {
55+
let b_len = b.chars().count();
56+
let mut costs: Vec<usize> = (0..=b_len).collect();
57+
58+
for (i, ca) in a.chars().enumerate() {
59+
let mut previous = costs[0];
60+
costs[0] = i + 1;
61+
for (j, cb) in b.chars().enumerate() {
62+
let insertion = costs[j + 1] + 1;
63+
let deletion = costs[j] + 1;
64+
let substitution = previous + usize::from(ca != cb);
65+
previous = costs[j + 1];
66+
costs[j + 1] = insertion.min(deletion).min(substitution);
67+
}
68+
}
69+
70+
costs[b_len]
71+
}
72+
73+
#[cfg(test)]
74+
mod tests {
75+
use super::*;
76+
77+
#[test]
78+
fn command_token_ignores_surrounding_whitespace() {
79+
assert_eq!(command_token(" !agent list "), "!agent");
80+
assert_eq!(command_token(""), "");
81+
assert_eq!(command_token(" "), "");
82+
}
83+
84+
#[test]
85+
fn positional_args_are_whitespace_based() {
86+
assert_eq!(first_arg("!agent details librarian"), Some("details"));
87+
assert_eq!(second_arg("!agent details librarian"), Some("librarian"));
88+
assert_eq!(first_arg("!agent"), None);
89+
assert_eq!(second_arg("!agent details"), None);
90+
}
91+
92+
#[test]
93+
fn command_suggestions_accept_missing_bang_and_small_typos() {
94+
assert_eq!(command_suggestion("agents"), Some("!agents"));
95+
assert_eq!(command_suggestion("!stats"), Some("!status"));
96+
assert_eq!(command_suggestion("!defualt"), Some("!default"));
97+
}
98+
99+
#[test]
100+
fn command_suggestions_ignore_unbounded_inputs() {
101+
let long = format!("!{}", "x".repeat(MAX_FUZZY_COMMAND_CHARS + 1));
102+
assert_eq!(command_suggestion(&long), None);
103+
}
104+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "find"
5+
require "pathname"
6+
7+
ROOT = Pathname.new(__dir__).join("..").expand_path
8+
MAX_NEW_RUST_LINES = 700
9+
10+
# Existing large files are technical debt, not precedent. Their budgets are
11+
# pinned to the line counts from the first architecture-ratchet pass; growing
12+
# one of these files should be an explicit decision, preferably paired with a
13+
# split or a tighter follow-up budget.
14+
RUST_LINE_BUDGETS = {
15+
"crates/adversary-detector/src/proxy.rs" => 730,
16+
"crates/adversary-detector/src/scanner.rs" => 1387,
17+
"crates/calciforge/src/adapters/codex_cli.rs" => 752,
18+
"crates/calciforge/src/adapters/mod.rs" => 1374,
19+
"crates/calciforge/src/adapters/openclaw_channel.rs" => 1768,
20+
"crates/calciforge/src/channels/matrix.rs" => 1815,
21+
"crates/calciforge/src/channels/mock.rs" => 804,
22+
"crates/calciforge/src/channels/signal.rs" => 1066,
23+
"crates/calciforge/src/channels/sms.rs" => 958,
24+
"crates/calciforge/src/channels/telegram.rs" => 2088,
25+
"crates/calciforge/src/channels/whatsapp.rs" => 1291,
26+
"crates/calciforge/src/commands.rs" => 4889,
27+
"crates/calciforge/src/config.rs" => 2364,
28+
"crates/calciforge/src/config/validator.rs" => 2419,
29+
"crates/calciforge/src/doctor.rs" => 3580,
30+
"crates/calciforge/src/install/cli.rs" => 1071,
31+
"crates/calciforge/src/install/executor.rs" => 3681,
32+
"crates/calciforge/src/install/linux_hardening.rs" => 751,
33+
"crates/calciforge/src/install/model.rs" => 819,
34+
"crates/calciforge/src/install/ssh.rs" => 1124,
35+
"crates/calciforge/src/install/wizard.rs" => 701,
36+
"crates/calciforge/src/providers/alloy.rs" => 1126,
37+
"crates/calciforge/src/proxy/gateway.rs" => 1001,
38+
"crates/calciforge/src/proxy/handlers.rs" => 2386,
39+
"crates/host-agent/src/main.rs" => 1288,
40+
"crates/paste-server/src/lib.rs" => 2623,
41+
"crates/security-proxy/src/mitm.rs" => 1573,
42+
"crates/security-proxy/src/proxy.rs" => 2295,
43+
"crates/security-proxy/src/substitution.rs" => 912,
44+
"crates/secrets-client/src/fnox_client.rs" => 787
45+
}.freeze
46+
47+
WATCH_PATTERNS = {
48+
"Arc<Mutex" => /Arc<Mutex/,
49+
"Arc<RwLock" => /Arc<RwLock/,
50+
"HashMap<String, String>" => /HashMap\s*<\s*String\s*,\s*String\s*>/,
51+
"Vec<String>" => /Vec\s*<\s*String\s*>/,
52+
"tokio::spawn" => /tokio::spawn\s*\(/,
53+
"positional indexing" => /\[[0-9]+\]/,
54+
"unsafe block" => /unsafe\s*\{/
55+
}.freeze
56+
57+
def rust_files
58+
files = []
59+
Find.find(ROOT.join("crates").to_s) do |path|
60+
next unless path.end_with?(".rs")
61+
next if path.include?("/target/")
62+
63+
files << Pathname.new(path)
64+
end
65+
files.sort
66+
end
67+
68+
def relative(path)
69+
path.relative_path_from(ROOT).to_s
70+
end
71+
72+
failed = false
73+
pattern_counts = Hash.new(0)
74+
75+
rust_files.each do |file|
76+
rel = relative(file)
77+
text = file.read
78+
line_count = text.lines.count
79+
budget = RUST_LINE_BUDGETS.fetch(rel, MAX_NEW_RUST_LINES)
80+
81+
if line_count > budget
82+
warn "#{rel}: #{line_count} lines exceeds architecture budget #{budget}"
83+
failed = true
84+
end
85+
86+
WATCH_PATTERNS.each do |name, regex|
87+
pattern_counts[name] += text.scan(regex).count
88+
end
89+
end
90+
91+
missing_budget_files = RUST_LINE_BUDGETS.keys.reject { |path| ROOT.join(path).file? }
92+
unless missing_budget_files.empty?
93+
warn "architecture budget references missing files:"
94+
missing_budget_files.each { |path| warn " #{path}" }
95+
failed = true
96+
end
97+
98+
puts "Architecture ratchets:"
99+
puts " max lines for new Rust modules: #{MAX_NEW_RUST_LINES}"
100+
puts " pinned large-module budgets: #{RUST_LINE_BUDGETS.length}"
101+
puts " watched pattern counts:"
102+
pattern_counts.sort.each do |name, count|
103+
puts " #{name}: #{count}"
104+
end
105+
106+
abort("architecture ratchets failed") if failed

0 commit comments

Comments
 (0)