rust-intel-cc 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ // Fan-out audit of a Rust project against the rust-intel skill.
2
+ // One agent per skill module (async.md splits into two), fed runtime slices of
3
+ // SKILL.md's trigger tables + 🔴 inventory by a slicer agent, scoped to the
4
+ // target crate by a scoper agent, then merged into a /rust-cc-audit report.
5
+ // Workflow({ scriptPath: "skill/audit-project.workflow.js",
6
+ // args: { target: "<crate dir>", skillDir: "<skill dir>" } })
7
+ // NO rule text lives here: knowledge is in the modules, process in the workflow,
8
+ // slices arrive at runtime. Keep MODULES/AUDIT_UNITS in sync with skill/.
9
+
10
+ export const meta = {
11
+ name: 'audit-rust-project',
12
+ description: 'Fan-out audit of a Rust project against rust-intel — one agent per skill module',
13
+ phases: [
14
+ { title: 'Prepare', detail: 'slice SKILL.md triggers + scope the target crate' },
15
+ { title: 'Audit', detail: 'one agent per module (async splits into two)' },
16
+ { title: 'Synthesize', detail: 'merge, dedup, format the report' },
17
+ ],
18
+ }
19
+
20
+ if (!args || !args.target) {
21
+ throw new Error('audit-rust-project: missing required arg "target" (path to the Rust crate to audit)')
22
+ }
23
+ if (!args.skillDir) {
24
+ throw new Error('audit-rust-project: missing required arg "skillDir" (path to the rust-intel skill dir holding SKILL.md + modules)')
25
+ }
26
+
27
+ // Module -> category-ids it owns. Mirrors the category->module map in SKILL.md.
28
+ const MODULES = [
29
+ { file: 'async.md', categories: ['B2','B3','B3a','B8','B11','B15','B15a','B15b','B15c','B15d','B15e','B21','B22','B23','C3','C9','E1'] },
30
+ { file: 'concurrency-and-state.md', categories: ['A2','B9','B10','B13','B14','B17','B19','C8','E4'] },
31
+ { file: 'data-and-types.md', categories: ['B6','B16','B20','B26','B27','B28','B29','C4','E2','E3'] },
32
+ { file: 'security.md', categories: ['B12','B24','C2'] },
33
+ { file: 'unsafe-and-ffi.md', categories: ['B5','B7','B18','B18a','B25'] },
34
+ { file: 'drop-and-raii.md', categories: ['B4','B4a'] },
35
+ { file: 'deps-macros-ergonomics.md', categories: ['A1','C5','C6','C7','C10','C11','E5'] },
36
+ { file: 'lifetimes-and-api.md', categories: ['B1','B1a','B1b','C1','C1a','A3'] },
37
+ { file: 'testing.md', categories: ['D1','D1a','D2','D3','D4','D5','E6'] },
38
+ { file: 'semantics-and-conformance.md', categories: ['F1','F2','F3','F4'] },
39
+ ]
40
+
41
+ // One audit agent per unit. async.md splits into two (discipline vs machinery, G6).
42
+ const AUDIT_UNITS = [
43
+ { module: 'async.md', label: 'async/discipline', onlyCategories: 'B2, B3, B3a, B8, B11, B21, B22, B23' },
44
+ { module: 'async.md', label: 'async/machinery', onlyCategories: 'B15a–e, C3, C9, E1' },
45
+ { module: 'concurrency-and-state.md', label: 'concurrency' },
46
+ { module: 'data-and-types.md', label: 'data-types' },
47
+ { module: 'security.md', label: 'security' },
48
+ { module: 'unsafe-and-ffi.md', label: 'unsafe-ffi' },
49
+ { module: 'drop-and-raii.md', label: 'drop-raii' },
50
+ { module: 'deps-macros-ergonomics.md', label: 'deps-macros' },
51
+ { module: 'lifetimes-and-api.md', label: 'lifetimes-api' },
52
+ { module: 'testing.md', label: 'testing' },
53
+ // §F1/§F2 need the project's own spec/README/docs, not just source — see scoper docsFiles + auditPrompt.
54
+ { module: 'semantics-and-conformance.md', label: 'semantics' },
55
+ ]
56
+
57
+ const SLICER_SCHEMA = {
58
+ type: 'object',
59
+ required: ['modules'],
60
+ properties: {
61
+ modules: {
62
+ type: 'array',
63
+ items: {
64
+ type: 'object',
65
+ required: ['module', 'phraseRows', 'codePatternRows', 'redItems'],
66
+ properties: {
67
+ module: { type: 'string', description: 'module filename, e.g. async.md' },
68
+ phraseRows: { type: 'string', description: 'verbatim phrase-trigger table rows whose Activates column names a § of this module' },
69
+ codePatternRows: { type: 'string', description: 'verbatim code-pattern table rows whose Activates column names a § of this module' },
70
+ redItems: { type: 'string', description: 'verbatim 🔴 enforcement-tier items belonging to this module' },
71
+ },
72
+ },
73
+ },
74
+ },
75
+ }
76
+
77
+ const SCOPER_SCHEMA = {
78
+ type: 'object',
79
+ required: ['versions', 'files', 'claudeMdNotes', 'docsFiles', 'docsDigest'],
80
+ properties: {
81
+ versions: { type: 'string', description: 'pinned versions from Cargo.toml (edition, key deps + their versions, tokio/etc)' },
82
+ files: { type: 'array', items: { type: 'string' }, description: 'list of *.rs source files, excluding target/ and generated files' },
83
+ claudeMdNotes: { type: 'string', description: 'project-specific constraints from CLAUDE.md if present, else empty' },
84
+ docsFiles: { type: 'array', items: { type: 'string' }, description: 'paths to spec/guarantee-bearing docs: README, SECURITY.md, docs/**, ARCHITECTURE/THREAT-MODEL, any cited RFC/spec file. For §F1/§F2.' },
85
+ docsDigest: { type: 'string', description: 'the project\'s stated guarantees and named specs/references, extracted verbatim where possible: what is secret, what input is untrusted, durability/ordering promises, "compatible with / port of X" claims. For §F1/§F2.' },
86
+ },
87
+ }
88
+
89
+ const FINDINGS_SCHEMA = {
90
+ type: 'object',
91
+ required: ['module', 'findings', 'redInventory', 'summary'],
92
+ properties: {
93
+ module: { type: 'string' },
94
+ findings: {
95
+ type: 'array',
96
+ items: {
97
+ type: 'object',
98
+ required: ['category', 'tier', 'severity', 'location', 'citation', 'why', 'fix'],
99
+ properties: {
100
+ category: { type: 'string', description: '§id, e.g. §B15a' },
101
+ tier: { type: 'string', description: '🔴 | 🟡 | 🟢' },
102
+ severity: { type: 'string', enum: ['critical', 'high', 'medium', 'info'] },
103
+ location: { type: 'string', description: 'file:line' },
104
+ citation: { type: 'string', description: 'short quoted code or anchor proving the finding' },
105
+ why: { type: 'string', description: 'one line: why this is a hazard, in the module\'s terms' },
106
+ fix: { type: 'string', description: 'concrete remedy' },
107
+ },
108
+ },
109
+ },
110
+ redInventory: {
111
+ type: 'array',
112
+ description: 'EVERY occurrence of this module\'s 🔴 items, even justified ones',
113
+ items: {
114
+ type: 'object',
115
+ required: ['redItem', 'location', 'status'],
116
+ properties: {
117
+ redItem: { type: 'string', description: '§id + short name of the 🔴 item' },
118
+ location: { type: 'string', description: 'file:line' },
119
+ status: { type: 'string', description: 'one-line status: violated / justified / N/A' },
120
+ },
121
+ },
122
+ },
123
+ summary: { type: 'string' },
124
+ },
125
+ }
126
+
127
+ function slicerPrompt() {
128
+ const map = MODULES.map((m) => `- ${m.file}: ${m.categories.join(', ')}`).join('\n')
129
+ return `You are slicing the trigger tables of the rust-intel skill so per-module audit agents each get only the rows relevant to their module.
130
+
131
+ Read: ${args.skillDir}/SKILL.md
132
+
133
+ It contains a phrase-trigger table and a code-pattern table; each row has an "Activates" column naming one or more § category ids. It also carries an enforcement-tier list marking some categories 🔴 (must report every occurrence).
134
+
135
+ For EACH module below, extract verbatim (do not paraphrase, copy the markdown rows exactly):
136
+ (a) phraseRows — every phrase-trigger row whose Activates column names ANY § in that module's category set,
137
+ (b) codePatternRows — every code-pattern row whose Activates column names ANY § in that module's category set,
138
+ (c) redItems — the 🔴 enforcement-tier items whose § belongs to that module.
139
+
140
+ A row may belong to several modules — duplicate it into each. If a module has none for a field, return an empty string for that field.
141
+
142
+ Module -> category ids:
143
+ ${map}
144
+
145
+ Return SLICER_SCHEMA: one entry per module above (key = filename), with the three verbatim text fields.`
146
+ }
147
+
148
+ function scoperPrompt() {
149
+ return `You are scoping a Rust crate for an audit.
150
+
151
+ Target crate dir: ${args.target}
152
+
153
+ 1. Read ${args.target}/Cargo.toml. Report the Rust edition, and the pinned versions of dependencies that matter for auditing (async runtime, sync/concurrency, serialization, crypto, FFI/bindgen, etc.) — name + version each.
154
+ 2. If ${args.target}/CLAUDE.md exists, read it and capture any project-specific constraints that change what counts as a finding (allowed unsafe, MSRV, forbidden deps, etc.). Otherwise leave empty.
155
+ 3. Inventory the *.rs source files under ${args.target}, EXCLUDING target/ and any generated files (build script output, *.gen.rs, OUT_DIR). Return their paths.
156
+ 4. Inventory the project's spec/guarantee-bearing docs (README, SECURITY.md, ARCHITECTURE/THREAT-MODEL, docs/**, and any RFC/spec file the code cites). Return their paths in docsFiles. Then read them and extract docsDigest: the stated guarantees (what is secret, what input is untrusted, durability/ordering promises) and every "compatible with / port of / implements <spec>" claim, verbatim where possible. This feeds the §F1/§F2 semantic-conformance audit; if there are no such docs, return empty docsFiles and an empty docsDigest.
157
+
158
+ Return SCOPER_SCHEMA.`
159
+ }
160
+
161
+ function auditPrompt(unit, slice, scope) {
162
+ const focus = unit.onlyCategories
163
+ ? `\nFocus ONLY on categories ${unit.onlyCategories}; ignore the rest of the module.\n`
164
+ : '\n'
165
+ // §F1/§F2 are unauditable from source alone — the defect lives in the gap between
166
+ // the code and the project's own spec/docs. Feed the semantics agent the doc digest.
167
+ const docs = unit.module === 'semantics-and-conformance.md'
168
+ ? `\nPROJECT SPEC / DOCS (the source of truth for §F1 conformance and §F2 documented guarantees — review the code AGAINST these, not against its own internal consistency):
169
+ Doc files: ${scope && scope.docsFiles && scope.docsFiles.length ? scope.docsFiles.join(', ') : '(none found — say so; "conformance not verified: no reference" is itself a finding)'}
170
+ Stated guarantees + named specs/references:
171
+ ${scope && scope.docsDigest ? scope.docsDigest : '(none extracted)'}
172
+ Read the doc files directly if you need more than the digest. For §F1, fetch/locate any cited RFC/spec and enumerate its mandated states before checking the code (Tier F stance: enumerate, don't sample).\n`
173
+ : ''
174
+ return `You are auditing ONE theme of rust-intel against real Rust code.
175
+
176
+ Read the module: ${args.skillDir}/${unit.module}
177
+ ${focus}${docs}
178
+ TIER SEMANTICS:
179
+ 🔴 = report EVERY occurrence (no judgement on whether it "looks fine").
180
+ 🟡 = report only when load-bearing / non-obvious — skip the trivial.
181
+ 🟢 = clippy's job; do NOT hand-report these.
182
+
183
+ ARTIFACT-VS-PROCESS: Audit the ARTIFACT — a BANNED pattern present in the code, or a REQUIRED code artifact that is absent. Process-REQUIREMENTs ("propose first", "ask the user", "get sign-off") are NOT auditable from source — do not emit pseudo-findings for them.
184
+
185
+ SLICE FOR THIS MODULE (from SKILL.md):
186
+ Code-pattern rows (use as starting grep targets):
187
+ ${slice && slice.codePatternRows ? slice.codePatternRows : '(none)'}
188
+
189
+ Phrase-trigger rows (context for what to look for):
190
+ ${slice && slice.phraseRows ? slice.phraseRows : '(none)'}
191
+
192
+ 🔴 enforcement items (must inventory every occurrence):
193
+ ${slice && slice.redItems ? slice.redItems : '(none)'}
194
+
195
+ TARGET CRATE:
196
+ Pinned versions:
197
+ ${scope ? scope.versions : '(unknown)'}
198
+ ${scope && scope.claudeMdNotes ? `Project constraints (CLAUDE.md): ${scope.claudeMdNotes}\n` : ''}Source files:
199
+ ${scope ? (scope.files || []).join('\n') : '(unknown)'}
200
+
201
+ METHOD:
202
+ 1. grep the code-pattern rows above as candidates across the source files.
203
+ 2. Read the surrounding context of each hit.
204
+ 3. Check it against the BANNED/REQUIRED text VERBATIM from the module — match the module's exact wording, not your prior.
205
+ 4. Honor every "don't flag X" / calibration note in the module.
206
+ 5. Do NOT invent findings — a short, honest report beats a synthetic one.
207
+
208
+ Return FINDINGS_SCHEMA. redInventory MUST list EVERY occurrence of this module's 🔴 items (file:line + one-line status), INCLUDING justified ones — these feed the Post-flight summary.`
209
+ }
210
+
211
+ phase('Prepare')
212
+ const prep = (await parallel([
213
+ () => agent(slicerPrompt(), { label: 'slicer', phase: 'Prepare', schema: SLICER_SCHEMA }),
214
+ () => agent(scoperPrompt(), { label: 'scoper', phase: 'Prepare', schema: SCOPER_SCHEMA }),
215
+ ])).filter(Boolean)
216
+
217
+ const slicerResult = prep.find((p) => Array.isArray(p.modules)) || null
218
+ const scoperResult = prep.find((p) => Array.isArray(p.files)) || null
219
+ if (!slicerResult) log('WARNING: slicer agent returned null — audit agents will have no SKILL.md slices')
220
+ if (!scoperResult) log('WARNING: scoper agent returned null — audit agents will have no version/file scope')
221
+
222
+ const sliceFor = (moduleFile) => {
223
+ if (!slicerResult) return null
224
+ return (slicerResult.modules || []).find((m) => m.module === moduleFile) || null
225
+ }
226
+
227
+ phase('Audit')
228
+ const auditResults = (await parallel(
229
+ AUDIT_UNITS.map((unit) => () =>
230
+ agent(auditPrompt(unit, sliceFor(unit.module), scoperResult), {
231
+ label: `audit:${unit.label}`,
232
+ phase: 'Audit',
233
+ schema: FINDINGS_SCHEMA,
234
+ })
235
+ )
236
+ )).filter(Boolean)
237
+
238
+ const dropped = AUDIT_UNITS.length - auditResults.length
239
+ if (dropped > 0) log(`WARNING: ${dropped} audit agent(s) returned null — those briefs were dropped`)
240
+
241
+ phase('Synthesize')
242
+ const synthesis = await agent(
243
+ `You are merging the results of a fan-out Rust audit into a single report. Below is JSON: an array of per-unit audit results (module, findings[], redInventory[], summary).
244
+
245
+ MERGE + DEDUP:
246
+ - Same file:line flagged by two agents -> keep ONE entry, prefer the more specific category, and note "also flagged by <other category>".
247
+ - Group by severity: critical -> high -> medium -> info. Within a severity, order by tier letter (A -> B -> C -> D -> E -> F).
248
+ - Do NOT invent findings not present in the input.
249
+
250
+ Format the report EXACTLY like this:
251
+
252
+ # rust-cc-audit report
253
+
254
+ **Scope:** ${args.target}
255
+ **Pinned versions:** <from scoper / the versions seen in input>
256
+ **Found:** N critical, M high, K medium, L info
257
+
258
+ ---
259
+
260
+ ## CRITICAL
261
+ ### [§XX] file:line — title
262
+ <citation, why, fix>
263
+
264
+ ## HIGH
265
+ ...
266
+
267
+ ## MEDIUM
268
+ ...
269
+
270
+ ## INFO
271
+ ...
272
+
273
+ ---
274
+
275
+ ## Post-flight summary
276
+ <aggregate ALL redInventory entries across every agent — list all 🔴 items, with their occurrences (file:line + status); write "none" for any 🔴 item with no occurrences>
277
+
278
+ JSON input:
279
+ ${JSON.stringify(auditResults)}`,
280
+ { label: 'synthesize', phase: 'Synthesize' }
281
+ )
282
+
283
+ return { report: synthesis }
@@ -0,0 +1,186 @@
1
+ # Rust Intel — Concurrency & Shared State (smart pointers, locks, races, channels, contention)
2
+
3
+ > Module of the **rust-intel** skill. Core — operating mode, blocking protocol, enforcement tiers, the trigger table, version pins, and the category→module map — lives in `SKILL.md`. This module holds the category bodies for §A2, §B9, §B10, §B13, §B14, §B17, §B19, §C8, §E4. Tier labels (🔴/🟡/🟢; A–F) and all cross-references are preserved verbatim.
4
+ > **Tiers in this module:** §A2 🟡 · §B9 🟡 · §B10 🟡 · §B13 🔴 (Relaxed-publish only; rest of body is 🟡) · §B14 🔴 · §B17 🟡 · §B19 🟡 · §C8 🟡 · §E4 🟡/🟢. Derived from SKILL.md → Enforcement tiers (canonical).
5
+ > **Audit semantics:** 🔴 = report every occurrence; 🟡 = write-time discipline — report only load-bearing/non-obvious cases; 🟢 = clippy's, don't hand-report. Audit the *artifact* (a BANNED pattern present, a REQUIRED code artifact absent); process-REQUIREMENTs ("propose first", "ask the user") are not auditable findings.
6
+
7
+ ---
8
+
9
+ ## §A2. Smart pointer misuse (reflexive `Arc<Mutex<T>>`)
10
+
11
+ **The trap**: this is Tier A because the LLM reaches for `Arc<Mutex<T>>` *in response to a compile error* — "needs to be Send, needs to be shared, needs interior mutability" — and the resulting code compiles, runs, and passes tests. The defect that survives is **structural**, not functional: gratuitous lock contention, wrong concurrency model, false sense that a critical section exists, refactor cost when the read/write ratio later argues for `Arc<RwLock<T>>`, `arc-swap`, or `Arc::make_mut`-style copy-on-write. The reverse trap — `Rc<RefCell<T>>` chosen for "local mutability" and later forced across threads — is the same shape: the original compile-time fix locked in a structural choice that the rest of the program then has to bend around.
12
+
13
+ **REQUIRED**:
14
+ - `Arc` only when ownership is genuinely shared across threads or async tasks. Single-owner sharing → `&` or `&mut`.
15
+ - `Mutex` only when interior mutability is actually needed. Read-only shared data → `Arc<T>` is enough.
16
+ - For shared data that is **mostly read, occasionally swapped wholesale**, prefer `arc_swap::ArcSwap<T>` or rebuild-then-`Arc::new`-and-swap, not `RwLock`.
17
+ - For **copy-on-write semantics on a single-owner-most-of-the-time `Arc`**, use `Arc::make_mut(&mut arc) -> &mut T` (clones the inner only if `strong_count > 1`; if the `Arc` is unique but live `Weak`s exist, they are **dissociated** — `upgrade()` then returns `None` — and no clone happens: fine for COW, surprising for a `Weak` observer). Mirror: `Rc::make_mut` for the non-thread-shared analog. For `Cow<'_, T>` semantics on borrow-or-own returns, prefer `std::borrow::Cow`.
18
+ - `Rc`/`RefCell` that cross an `.await` reachable from a *multi-threaded* executor (`tokio::spawn` on the default runtime) are wrong — but the compiler already rejects them (`!Send`, an `E0277` outside this spec's scope). They stay perfectly legitimate in single-threaded-by-contract async (`tokio::task::spawn_local` / `LocalSet`) and in ordinary synchronous single-threaded code (parsers, AST/IR builders, local graphs) — do not flag those. The real rule: if the data must move across threads, use `Arc` + a lock from `tokio::sync` or `std::sync` per §B2; if unsure of the threading model, default to `Arc`.
19
+ - Boxing a small `Sized` scalar (≤ 2 × pointer size) *for the sake of boxing* is a smell — don't box `i64`, `Option<u32>`, or a small enum just to add a heap indirection. Legitimate reasons to box even a small `T` exist and are not the target of this rule: breaking a recursive type (`struct Node { next: Option<Box<Node>> }`), pinning a value to a stable heap address (`Pin<Box<T>>`, self-referential futures), or erasing behind `Box<dyn Trait>`. Box deliberately, not reflexively.
20
+
21
+ **BANNED**:
22
+ - `Arc<Mutex<T>>` where `T` is only ever read after construction. Use `Arc<T>` (or `ArcSwap<T>` if it must change).
23
+ - `Arc<RwLock<T>>` for write-heavy workloads. Profile first; `Mutex` is often faster.
24
+ - Cloning the inner `T` via `(*arc).clone()` when `Arc::make_mut` would be both cheaper (on the unique-owner path) and clearer.
25
+ - `Box::leak(Box::new(...))` to obtain a `&'static` for a global. It is an intentional, unrecoverable leak that grows on every re-init path (config hot-reload, repeated bootstrap, per-test setup). Use `OnceLock` / `LazyLock` (stable ≥ 1.80) for lazily-initialized globals.
26
+ - A `LazyLock::new(|| …)` / `OnceLock` init closure that can panic (reads env/file/network and `.unwrap()`s) poisons the cell: every later access panics, not just the first. Don't panic in lazy init — validate fallibly before, or store a `Result` and handle it at each access.
27
+ - `RefCell<T>` for a `Copy` (or replace-whole) interior where `Cell<T>` would do. `Cell` has no runtime borrow flag and so cannot trigger the §B17 `BorrowMutError` panic; reach for `RefCell` only when you need `&`/`&mut` into the interior.
28
+
29
+ ## §B9. Lock ordering and ABBA deadlock
30
+
31
+ **The trap**: two locks (`Mutex<A>`, `Mutex<B>`) acquired in opposite orders in different code paths. Function `f1` locks A then B; function `f2` locks B then A. Single-threaded tests pass trivially. Multi-threaded production hits the classic deadlock: thread 1 holds A waiting for B, thread 2 holds B waiting for A, both wait forever.
32
+
33
+ **Why this happens**: LLMs treat lock acquisition as a local concern. The deadlock is a global property of the program's lock graph, invisible from any single function. No lint detects it.
34
+
35
+ **Prompt triggers**: "synchronize access to two shared resources", "lock the cache and the queue", "update state and metrics atomically", anything involving two `Arc<Mutex<_>>` in the same operation.
36
+
37
+ **REQUIRED**:
38
+ - For any code path that acquires more than one lock, **document the lock acquisition order** as a doc comment at the top of the module or function. State it in a comment LLM-readable enough that future generations of this file maintain it.
39
+ - Use a consistent lock ordering across the entire crate. Common conventions: alphabetical by name, by declaration order in the struct, by a numeric rank field.
40
+ - Prefer fine-grained immutable data + message passing (`mpsc`, `oneshot`) over multi-lock critical sections when possible.
41
+ - When two locks must be held, take them **in the documented order, every time, without exception**.
42
+ - For async code, prefer `tokio::sync::Mutex`. Deadlock *detection* is not automatic with this choice: `tokio-console` provides **visibility** (you can see which task holds which lock and which is waiting), not detection. Detection of cycles must be wired explicitly — `parking_lot::deadlock::check_deadlock()` for sync sections (requires the `deadlock_detection` cargo feature on `parking_lot`; the module does not exist without it), periodic graph audit of the documented lock-acquisition order for async sections. The async `Mutex` itself gives no deadlock signal on its own.
43
+
44
+ **BANNED**:
45
+ - Holding two locks across a function call (the called function may acquire locks in another order).
46
+ - Acquiring a second lock while holding the first if the second one's acquisition can block on async work or I/O.
47
+ - "Just try locking" patterns with `try_lock` to escape suspected deadlocks — that hides the design problem. This bans the *reflex*, not the technique: `try_lock` + backoff with a documented retry policy is a legitimate hierarchical-locking / backoff design; what is banned is reflexive `try_lock` as an escape from a deadlock you have not actually diagnosed.
48
+ - **`std::thread::scope` (stable 1.63) — sync mirror of §B21**: where `tokio::spawn` *detaches* the work and loses it (§B21), `scope` *force-joins* every child thread on the closing brace. Two silent consequences. (a) A child waiting for a resource the parent only releases *after* the scope deadlocks the closing brace — a non-lock deadlock, invisible to §B9 lock-graph analysis. (b) A panic in a child **re-panics in the parent** when `scope` returns (after auto-joining all un-joined handles — propagation via `resume_unwind`, not at the `Scope` struct's `Drop`), skipping any cleanup the parent intended between the spawn site and the closing brace — RAII discipline must therefore not assume "the parent code after the scope runs". REQUIRED: guarantee that children are reachable to completion before the closing brace (close the channel, drop the sender, signal cancellation — *before* the scope ends, not after); handle child panics explicitly (`JoinHandle::join` on each `ScopedJoinHandle` and inspect the `Result`) when cleanup must run regardless. 🟡.
49
+
50
+ **Detection**: add `tokio-console` for runtime visibility, or `parking_lot::deadlock` detection in dev builds (gated behind `parking_lot`'s `deadlock_detection` feature). Note each double-lock site inline (at write time).
51
+
52
+ ## §B10. Reference cycles in `Rc`/`Arc` graphs
53
+
54
+ **The trap**: when LLMs build graph or tree structures with parent-child relationships, they reach for `Rc<RefCell<Node>>` (or `Arc<Mutex<Node>>`) and create *both* parent→child and child→parent strong references. This creates a reference cycle. Rust has no garbage collector. Memory leaks. Tests pass because functionality (insert, traverse, lookup) works correctly. Memory is never reclaimed; as the structure grows, RSS climbs steadily — an OOM in production rather than at test time.
55
+
56
+ **Why this happens**: LLM training corpus has plenty of "graph in Rust" examples, but the `Weak` pattern is underrepresented. The model defaults to symmetric strong references.
57
+
58
+ **Prompt triggers**: "build a tree with parent links", "graph data structure", "linked list with previous pointer", "DOM-like structure", "scene graph", any bidirectional ownership.
59
+
60
+ **BANNED**:
61
+ - `Rc<RefCell<T>>` or `Arc<Mutex<T>>` on both sides of a bidirectional reference.
62
+ - "Parent owns children, children own parent" patterns.
63
+
64
+ **REQUIRED**:
65
+ - One direction is `Rc<T>` (or `Arc<T>`), the other is `Weak<T>`. Convention: parent owns children with `Rc`, children point to parent with `Weak`.
66
+ - For any graph structure with cycles, prefer arena-style storage: `Vec<Node>` + `NodeId(usize)` indices. No reference cycles possible, no `RefCell` overhead, better cache locality. Crates: `slotmap`, `id-arena`, `petgraph`.
67
+ - When `Weak::upgrade()` returns `None`, treat it as a normal case (parent has been dropped), not an error.
68
+
69
+ **Detection**: profile with `heaptrack` or `valgrind --tool=massif` for steady-state memory growth. In dev builds, periodically print `Rc::strong_count(&node)` for representative nodes.
70
+
71
+ ## §B13. Check-then-act races in concurrent collections (TOCTOU)
72
+
73
+ **The trap**: LLMs port single-threaded patterns from Python/JS/Java into multi-threaded Rust. The canonical example is the "lazy cache":
74
+
75
+ ```rust
76
+ // BANNED — race between contains_key and insert
77
+ if !cache.contains_key(&key) {
78
+ let value = expensive_fetch(&key).await;
79
+ cache.insert(key, value);
80
+ }
81
+ ```
82
+
83
+ In a single-threaded test, this is correct. Under concurrent load, N threads simultaneously see "key is absent", N threads simultaneously call `expensive_fetch`, and only one write actually wins. The cache works *functionally* — every lookup returns a value — but the "expensive" function is called N times when it should have been called once. Variants of this pattern fail similarly: read-modify-write on a counter, "if absent insert default else update", lazy initialization with `bool` flag.
84
+
85
+ **Why this happens**: in single-threaded languages, check-then-act is sound. The model has a strong prior on it. The Time-of-Check-to-Time-of-Use (TOCTOU) gap is invisible from a single function's perspective.
86
+
87
+ **Prompt triggers**: "cache", "memoize", "lazy initialization", "ensure exactly one X", "deduplicate", "if not exists, create".
88
+
89
+ **BANNED**:
90
+ - `if !map.contains_key(k) { map.insert(k, v); }` and any variation where check and act are separate calls — the same pattern via `HashMap::iter` + `HashMap::insert` is equally broken.
91
+ - `if map.contains_key(k) { let v = map.get(k).unwrap(); ... }` — between the check and the get, another thread could remove the entry, and `.unwrap()` panics.
92
+ - "Two-phase commit"-style patterns across separate operations on a concurrent collection.
93
+ - `let x = *counter.lock().unwrap(); *counter.lock().unwrap() = x + 1;` — read and write are separate critical sections, a thread can interleave.
94
+ - `if Arc::strong_count(&arc) == 1 { ... unique-owner logic ... }` — count can change between read and use under any concurrent code. Use `Arc::into_inner(arc)` (returns `Option<T>` if unique) or `Arc::try_unwrap(arc)` (returns `Result<T, Arc<T>>`); the atomic variant is the only check-and-act pattern that's race-free. Prefer `into_inner` over `try_unwrap(...).ok()` — discarding the `Err` arm reintroduces a drop-race on the last reference (std documents this); `into_inner` guarantees exactly one caller observes `Some`.
95
+ - `Ordering::Relaxed` on an atomic used to *publish* data to another thread (e.g. write the payload, then `flag.store(true, Relaxed)`; the reader does `flag.load(Relaxed)` then reads the payload). `Relaxed` establishes **no happens-before** edge, so the reader may observe the flag set before the payload writes are visible — a data race that x86's strong memory model usually hides in tests but that breaks on ARM/AArch64 under reordering. Use `Release` on the store and `Acquire` on the load (or `AcqRel`/`SeqCst` for read-modify-write) whenever the atomic guards access to other data.
96
+
97
+ **REQUIRED**:
98
+ - For synchronous "insert if absent": `map.entry(key).or_insert_with(|| compute_value())` (synchronous only — see §B2: never hold this guard across `.await`). On a plain `std::collections::HashMap` the atomicity comes from the `&mut self` borrow (it is single-threaded — there is no internal lock); on a concurrent `dashmap::DashMap`, `entry` holds the shard lock across check and act. Either way the check and the act are one operation, not two.
99
+ - For an **async** compute that must run exactly once under concurrent load (the lazy-cache example this category opens with), `or_insert_with` cannot help — its closure is synchronous and cannot `.await`. Store a once-cell per key: `let slot = map.entry(key).or_insert_with(|| Arc::new(tokio::sync::OnceCell::new())).clone();` then `slot.get_or_init(|| async { expensive_fetch().await }).await` — only one task runs the fetch, the rest await the same cell.
100
+ - For `DashMap`: `dashmap::DashMap::entry(key).or_insert_with(...)`.
101
+ - For atomic counters: `AtomicUsize::fetch_add(1, Ordering::Relaxed)`, not lock-load-add-store.
102
+ - For "compare and swap" patterns: `Atomic*::compare_exchange` or `Atomic*::fetch_update`.
103
+ - For ordered iteration of map keys, use `BTreeMap` (sorted by key) or collect to `Vec` and `sort_by`. `HashMap::iter` order is randomized per-process and per-rehash; relying on it makes tests flake across machines.
104
+ - `Relaxed` is correct only for standalone counters/statistics where no other memory is published through the atomic. The moment the atomic gates visibility of other data, you need `Acquire`/`Release`. Don't blanket-`SeqCst` to "be safe" — it hides the wrong mental model and costs a fence; reason about the happens-before edge explicitly, and model-check multi-atomic code with `loom` (already in the post-flight list).
105
+
106
+ **Detection**: this is invisible to type checking and almost always invisible to tests. The defense is recognizing the pattern at write time. (Enforcement: the TOCTOU patterns here are 🟡 — recognized and fixed at write time; only the `Relaxed`-publish data race is 🔴 surface-always.) If a function does two consecutive operations on a shared collection, it is a candidate.
107
+
108
+ ## §B14. Unbounded channels and backpressure neglect
109
+
110
+ **The trap**: when the producer/consumer rate is unbalanced, an `mpsc::unbounded_channel` doesn't block the producer — it just lets the queue grow. Tests with 5–100 messages pass. Production with a producer that's 2× faster than the consumer accumulates millions of pending messages, RAM climbs steadily, and the OOM killer eventually terminates the process — usually under peak load when it hurts most.
111
+
112
+ **Why this happens**: bounded channels force the producer to handle "channel is full" via `try_send`/`send` errors; `unbounded` has the simpler API and is the LLM's path of least resistance — the §C5 reflexive-fix pattern applied to channel selection.
113
+
114
+ **Prompt triggers**: "send events to a worker", "background queue", "log messages to a task", "producer-consumer", "event bus", "websocket broadcast", "metrics pipeline".
115
+
116
+ **BANNED** in any non-trivial pipeline:
117
+ - `tokio::sync::mpsc::unbounded_channel()` without explicit justification that the producer rate is provably bounded by an external invariant.
118
+ - `flume::unbounded()`, `async_channel::unbounded()` for the same reason.
119
+ - A `Vec` that is `push`-ed in a hot loop with no consumer or cap — same failure shape as an unbounded channel, different surface. `Vec::push` itself is fine (amortized O(1)); the failure is the missing drain or bound.
120
+ - Treating `tokio::sync::broadcast::error::RecvError::Lagged(n)` as a transient error to retry past. `Lagged(n)` means the receiver fell more than the channel's capacity behind the sender and **`n` messages are gone forever** — the receiver has already skipped to the oldest still-buffered message. A `match { Err(Lagged(_)) => continue, ... }` loop recovers nothing and silently masks data loss as a hiccup. On `Lagged`, log/metric the skipped count and decide explicitly whether dropping is acceptable or the consumer must be made faster / the buffer larger.
121
+ - `FuturesUnordered` (or `JoinSet`) grown by unbounded `.push()` with no cap — the same unbounded-growth hazard as an unbounded channel, just wearing a different type. Separately: an **empty** `FuturesUnordered` polled in a `select!` arm returns `Poll::Ready(None)` immediately, so a `loop { select! { x = futs.next() => ... } }` busy-spins at 100% CPU when `futs` is empty. Guard with `if !futs.is_empty()` or a fallback arm.
122
+ - A long synchronous step inside a `FuturesUnordered` / `buffer_unordered` loop body *buries* the sibling futures in the set — they are polled only when the set is polled, so external timeouts can fire spuriously and futures awaiting a shared semaphore inside the set can self-deadlock (holding permits while a queued item waits for one). Keep work that runs between polls short.
123
+
124
+ **REQUIRED**:
125
+ - Default to **bounded** channels: `tokio::sync::mpsc::channel(N)`. Size `N` from the actual constraints, not from a folk number: large enough to absorb the *expected producer burst over one consumer cycle*, small enough that `N × sizeof(message)` fits the per-task memory budget. If the right `N` cannot be reasoned about, that is a signal that the backpressure policy itself needs design before the channel is written. Never `unbounded`.
126
+ - Decide the **backpressure policy** explicitly: block the producer (default `send().await`), drop oldest (`try_send` with explicit drop), drop newest (`try_send` returning error → log and discard), or apply rate limiting upstream. State the choice in a comment.
127
+ - For broadcast scenarios where slow consumers shouldn't slow producers: `tokio::sync::broadcast::channel(N)` with explicit handling of `RecvError::Lagged` (which indicates dropped messages).
128
+ - For any unbounded queue that *must* exist (e.g., legacy interop): expose its size as a metric and alert when it grows abnormally.
129
+
130
+ **Detection**: unbounded channel growth doesn't appear in tests. Defense is at write time (default to bounded) and via monitoring (track `Sender::capacity()` or queue length as a metric in production).
131
+
132
+ ## §B17. `RefCell` / `Mutex` runtime borrow panics
133
+
134
+ §A2 covers the thread-safety dimension of choosing smart pointers; this category covers the **single-threaded** reentrant-borrow hazard that `Rc<RefCell<T>>` introduces even when threading is not involved.
135
+
136
+ **The trap**: `RefCell` enforces borrow rules at runtime via panics. The borrow check is dynamic, not static — and the LLM writes call patterns that *can* reach a second `borrow_mut()` while the first is still live, but the test inputs never exercise the path. Compiles; passes tests at low fanout; panics in production the moment a callback chain or trait dispatch reenters the cell. The async-runtime mirror: `tokio::sync::Mutex` does not panic on reentrance, it *deadlocks* — the second `.lock().await` waits forever for the first guard, which is held by the same task.
137
+
138
+ **BANNED**:
139
+ - `Rc<RefCell<T>>` or `Arc<RefCell<T>>` for shared mutable state accessed through nested callbacks, closures, or trait-object dispatch where the call graph is not statically obvious. (`Arc<RefCell<T>>` is `!Send` + `!Sync`, so it does not compile *when sent across threads* — it is perfectly fine in single-threaded code; but `Arc<Mutex<T>>` with the same access pattern has the same logical defect, just expressed as a deadlock instead of a panic.)
140
+ - `cell.borrow_mut()` inside a scope that later calls into code (closure, trait method, observer notification, callback registration) that can re-enter the same `RefCell`. Even if the test path doesn't exercise the reentrance, the structural risk is there.
141
+ - Holding a `tokio::sync::MutexGuard` across an `.await` that ends up calling back into the same `Mutex` — guaranteed deadlock.
142
+
143
+ **REQUIRED**:
144
+ - For sync interior mutability accessed in tree traversal, observer notification, or callback chains, use `try_borrow_mut()` and handle `BorrowMutError` instead of unconditional `borrow_mut()`. The error path becomes a real recovery path, not a panic.
145
+ - Document the borrow-disjointness invariant at the *type* level: newtype with private field, public methods that guarantee non-overlapping borrows by construction. The invariant becomes a comment on the newtype, not a hope.
146
+ - For `tokio::sync::Mutex`, document a lock-acquisition order per §B9 — including "no method on this type calls back into self via another lock acquisition".
147
+
148
+ ## §B19. Iterator invalidation through indirection
149
+
150
+ **The trap**: for a plain `&mut Vec<T>`, the borrow checker statically forbids iterating-while-mutating. The LLM writes the same pattern *through* a `RefCell`, through indices, or through `unsafe` raw pointers — and the borrow checker no longer sees it. Compiles, passes tests for small inputs, and corrupts state (or panics on `BorrowMutError`) once the loop body actually triggers the mutation under realistic input.
151
+
152
+ **BANNED**:
153
+ - Iterating `vec.iter()` (or `borrow.iter()`) while pushing/removing through a `RefCell<Vec<T>>` borrow on the same vector inside the loop body — the iteration sees an inconsistent snapshot and may dangle.
154
+ - `for i in 0..vec.len() { ... vec.push(...) ... }` — `vec.len()` is captured once at the start of the range; if the loop body mutates `vec.len()`, the loop iterates over the *old* length, missing or double-processing newly-inserted items.
155
+ - BFS/DFS that pushes children onto the same `Vec` it is iterating, indexed by `for i in 0..frontier.len()` — produces silent partial traversal.
156
+ - `std::mem::take(&mut field)` and `Option::take` leave a `Default` (`Vec::new()`, `None`, `0`) behind; `mem::replace(&mut field, new)` leaves the passed-in `new`. Either way the field no longer holds the original, so the "take it out, process, put it back" pattern silently loses the original contents if an early `return`, `?`, or panic happens between the take and the put-back. Restore on every path, or use a drop guard that puts the value back.
157
+
158
+ **REQUIRED**:
159
+ - For BFS/DFS with a growing frontier, use **two vectors** (`current`, `next`) and `std::mem::swap(&mut current, &mut next)` between layers, or `VecDeque` with disciplined `pop_front` / `push_back` and a captured *initial* layer length.
160
+ - For loops whose body must read the source after potentially-mutating it, snapshot first: `let snapshot: Vec<_> = vec.iter().cloned().collect();` then iterate the snapshot, then commit changes. The clone cost is the price of avoiding undefined behavior at the data-structure level.
161
+ - For index loops over mutating collections, re-read `len()` every iteration (`while i < vec.len() { ... i += 1; }`) and state in a comment why the loop is well-founded.
162
+
163
+ ## §C8. Channel-and-runtime mismatch
164
+
165
+ **The trap**: the LLM picks a channel by name recognition — `std::sync::mpsc` because it's standard, `crossbeam::channel` because it's "the fast one", `tokio::sync::mpsc` because it's the tokio one. The code compiles in all four runtime/channel combinations. Behaviour diverges: a sync channel in async code blocks the executor (§B11 surface); a tokio MPSC where multi-consumer is needed silently fans messages to whichever receiver wins the race; a `crossbeam::channel::Receiver::recv()` inside an `async fn` blocks the worker thread for as long as the queue is empty.
166
+
167
+ **BANNED**:
168
+ - `std::sync::mpsc::Receiver::recv()` inside an `async fn` or any function called from `tokio::spawn` — blocks the worker thread; same defect as `std::thread::sleep` per §B11.
169
+ - `tokio::sync::mpsc::channel(...)` when the workload is multi-consumer — `Receiver` is single-consumer by type (only one task can hold it). Spawning multiple tasks that each call `recv()` on a *cloned* receiver is not possible; cloning is not implemented. Use `broadcast` or `flume` instead.
170
+ - `crossbeam::channel::Receiver::recv()` inside async code — sync API, blocks the worker. `crossbeam` is fine in pure-sync contexts (rayon, OS threads); not under tokio.
171
+ - `tokio::sync::mpsc::Sender::send(...)` (await form) inside a fast sync producer that cannot afford the await point — use `try_send` and handle the `TrySendError::Full` explicitly (§B14 backpressure).
172
+
173
+ **REQUIRED**:
174
+ - **Async multi-producer / single-consumer**: `tokio::sync::mpsc::channel(N)` (bounded; default).
175
+ - **Async multi-producer / multi-consumer**: `flume::bounded(N)` (works in both sync and async modes) or `tokio::sync::broadcast::channel(N)` — note the semantics divergence: `broadcast` delivers every message to every receiver and signals lag via `RecvError::Lagged`, whereas `flume` MPMC distributes each message to one receiver.
176
+ - **Sync MPMC**: `crossbeam::channel::bounded(N)` or `flume::bounded(N)` in sync mode.
177
+ - **Async single-producer / single-consumer**: `tokio::sync::oneshot::channel()` for one-shot; `tokio::sync::mpsc::channel(1)` for streamed.
178
+ - **Async with priorities**: build on `tokio::sync::Mutex<BinaryHeap<_>>` + a `Notify` for wake-ups, or use the `priority-queue` crate inside a `Mutex`. There is no standard async priority channel; document the choice.
179
+
180
+ ## §E4. Contention that serializes — *A lock is a queue; under load, the queue is your latency.*
181
+
182
+ - **Where it shows up**: `Arc<Mutex<T>>` reached for reflexively (§A2) where the data is read-mostly, swapped wholesale, or never actually shared mutably; a critical section spanning I/O, allocation, or `format!` (§B2); a single global lock where work shards cleanly per key/connection; a lock taken inside a hot loop; two atomics (or a lock and its payload) sharing one cache line (false sharing).
183
+ - **The cheaper move**: match the tool to the access shape — a plain atomic for a counter/flag (§B13); `arc_swap::ArcSwap` or `Arc<T>`+rebuild-and-swap for read-mostly config; `RwLock` only when reads truly dominate and the section is non-trivial; sharding (array of locks keyed by hash); a channel to hand ownership to one owner. Shrink every critical section to "read a few fields, clone what's needed, drop the guard." Pad hot independent atomics with `crossbeam_utils::CachePadded`.
184
+ - **Hasher by trust boundary**: the default `HashMap` hasher (SipHash-1-3, randomly seeded) is DoS-resistant, not fast. For internal, trusted keys — especially integer/small keys on a hot path — a faster hasher (`rustc_hash::FxHashMap`, `foldhash`, `ahash`) is a real win. For attacker-influenced keys the speed is a trap: a fixed-seed fast hasher reopens HashDoS (§B16). The trust boundary is the whole decision, not the benchmark.
185
+ - **Leave it when**: contention is unmeasured and the lock is held briefly on a cold path — a `Mutex` is often faster than an `RwLock` and clearer than a lock-free scheme.
186
+ - 🟡. Cross: §A2, §B2, §B13, §B16. Verify any new crate before adding — §A1. The concurrent-map selection table (sharded / lock-free-read / read-copy families) and the hasher ladder live in the **Substitution catalog** in `data-and-types.md`.