doctrine 0.14.0

Project tooling CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
// SPDX-License-Identifier: GPL-3.0-only
//! `doctrine` worker-mode guard — `WriteClass`, `write_class`, `worker_guard`.
//! SL-129: uses `entity::id_path`, `clock::today`

use crate::boot::BootCommand;
use crate::commands::cli::Command;
use crate::commands::config::ConfigCommand;
use crate::commands::reservation::ReservationCommand;
use crate::knowledge::KnowledgeCommand;
use crate::policy::PolicyCommand;
use crate::rec::RecCommand;
use crate::rfc::RfcCommand;
use crate::standard::StandardCommand;

/// Mutation classification for the worker-mode guard (ADR-006 D2a). `Write`
/// carries the verb label named in the refusal. EXHAUSTIVE by design (§7-D6):
/// no wildcard arm, so a future `Command` variant is a compile error — never a
/// silently-permitted write (the X4 self-defence).
pub(crate) enum WriteClass {
    Read,
    Write(&'static str),
    /// Orchestrator-only privileged verbs (SL-056 PHASE-06): `fork` is the FIRST
    /// member; later phases add `import`/`land`/`gc`. Carries the verb label like
    /// `Write`. REFUSED under worker-mode — these are the orchestrator's funnel
    /// operations, never a worker's.
    Orchestrator(&'static str),
    /// `worktree marker --clear` (SL-056 §3, §5): a bespoke class that the
    /// worker-mode guard does NOT refuse (locking the marker's only remover behind
    /// the marker is a self-brick we reject). Its own bespoke refusals live in
    /// `run_marker_clear`.
    MarkerClear,
    /// `worktree marker --stamp-subagent` (SL-056 PHASE-10): the claude harness
    /// spawn path's provision+mark step. REFUSED under worker-mode via the SAME
    /// branch as `Orchestrator`/`Write` — NO verb-identity carve-out. The legit
    /// first stamp passes automatically: the target worktree bears no marker yet,
    /// so `worker_mode == false` (marker-absent ⇒ allow). Carries the verb label.
    Hookmint(&'static str),
}

#[expect(
    clippy::match_same_arms,
    reason = "consecutive Read arms across different nested-match shapes; merging would degrade readability"
)]
pub(crate) fn write_class(cmd: &Command) -> WriteClass {
    use super::cli::ExportCommand;
    use super::coverage::CoverageCommand;
    use crate::adr::AdrCommand;
    use crate::backlog::BacklogCommand;
    use crate::concept_map::ConceptMapCommand;
    use crate::dispatch::{CandidateCommand, DispatchCommand};
    use crate::memory::{MemoryCommand, SyncCommand};
    use crate::review::ReviewCommand;
    use crate::revision::{RevisionChangeCommand, RevisionCommand};
    use crate::spec::{SpecCommand, SpecReqCommand};
    use crate::worktree::WorktreeCommand;
    use WriteClass::{Hookmint, MarkerClear, Orchestrator, Read, Write};
    match cmd {
        Command::Install { .. } => Write("install"),
        Command::Map { .. } => Write("map"),
        Command::ConceptMap { command } => match command {
            ConceptMapCommand::New { .. } => Write("concept-map new"),
            ConceptMapCommand::Add { .. } => Write("concept-map add"),
            ConceptMapCommand::Remove { .. } => Write("concept-map remove"),
            ConceptMapCommand::RenameNode { .. } => Write("concept-map rename-node"),
            ConceptMapCommand::List { .. }
            | ConceptMapCommand::Show { .. }
            | ConceptMapCommand::Check { .. }
            | ConceptMapCommand::Export { .. }
            | ConceptMapCommand::Paths { .. } => Read,
        },
        Command::Slice { command } => match command {
            crate::slice::SliceCommand::New { .. } => Write("slice new"),
            crate::slice::SliceCommand::Design { .. } => Write("slice design"),
            crate::slice::SliceCommand::Plan { .. } => Write("slice plan"),
            crate::slice::SliceCommand::Phases { .. } => Write("slice phases"),
            crate::slice::SliceCommand::Notes { .. } => Write("slice notes"),
            crate::slice::SliceCommand::Phase { .. } => Write("slice phase"),
            crate::slice::SliceCommand::RecordDelta { .. } => Write("slice record-delta"),
            crate::slice::SliceCommand::Status { .. } => Write("slice status"),
            crate::slice::SliceCommand::ReconcilePhases { .. } => {
                Write("slice reconcile-phases")
            }
            crate::slice::SliceCommand::List { .. }
            | crate::slice::SliceCommand::Show { .. }
            | crate::slice::SliceCommand::Conformance { .. }
            | crate::slice::SliceCommand::VerifyVt { .. }
            | crate::slice::SliceCommand::Paths { .. } => Read,
            crate::slice::SliceCommand::Selector { command } => match command {
                // `doctor` is a read-only advisory report (SL-190 PHASE-06).
                crate::slice::SelectorCommand::Doctor { .. } => Read,
                crate::slice::SelectorCommand::Add { .. }
                | crate::slice::SelectorCommand::Note { .. }
                | crate::slice::SelectorCommand::List { .. }
                | crate::slice::SelectorCommand::Rm { .. } => Write("slice selector"),
            },
        },
        Command::Memory { command } => match command {
            MemoryCommand::Record { .. } => Write("memory record"),
            MemoryCommand::Verify { .. } => Write("memory verify"),
            MemoryCommand::Sync { command, .. } => match command {
                None => Write("memory sync"),
                Some(SyncCommand::Install { .. }) => Write("memory sync install"),
            },
            MemoryCommand::Tag { .. } => Write("memory tag"),
            MemoryCommand::Status { .. } => Write("memory status"),
            MemoryCommand::Edit { .. } => Write("memory edit"),
            MemoryCommand::Validate { .. }
            | MemoryCommand::Show { .. }
            | MemoryCommand::List { .. }
            | MemoryCommand::Search { .. }
            | MemoryCommand::Retrieve { .. }
            | MemoryCommand::ResolveLinks { .. }
            | MemoryCommand::Backlinks { .. }
            | MemoryCommand::Paths { .. } => Read,
        },
        Command::Review { command } => match command {
            ReviewCommand::New { .. } => Write("review new"),
            ReviewCommand::Raise { .. } => Write("review raise"),
            ReviewCommand::Dispose { .. } => Write("review dispose"),
            ReviewCommand::Verify { .. } => Write("review verify"),
            ReviewCommand::Contest { .. } => Write("review contest"),
            ReviewCommand::Withdraw { .. } => Write("review withdraw"),
            ReviewCommand::Unlock { .. } => Write("review unlock"),
            ReviewCommand::List { .. }
            | ReviewCommand::Show { .. }
            | ReviewCommand::Status { .. }
            | ReviewCommand::Prime { .. }
            | ReviewCommand::Paths { .. } => Read,
        },
        Command::Rec { command } => match command {
            RecCommand::New { .. } => Write("rec new"),
            RecCommand::List { .. } | RecCommand::Show { .. } | RecCommand::Paths { .. } => Read,
        },
        Command::Revision { command } => match command {
            RevisionCommand::New { .. } => Write("revision new"),
            RevisionCommand::Status { .. } => Write("revision status"),
            RevisionCommand::Show { .. }
            | RevisionCommand::Paths { .. }
            | RevisionCommand::List { .. } => Read,
            RevisionCommand::Change { command } => match command {
                RevisionChangeCommand::Add { .. } => Write("revision change add"),
            },
            RevisionCommand::Approve { .. } => Write("revision approve"),
            RevisionCommand::Apply { .. } => Write("revision apply"),
        },
        // Writes authored requirement status + an authored REC — an authored write.
        Command::Reconcile { .. } => Write("reconcile"),
        Command::Adr { command } => match command {
            AdrCommand::New { .. } => Write("adr new"),
            AdrCommand::Status { .. } => Write("adr status"),
            AdrCommand::List { .. } | AdrCommand::Show { .. } | AdrCommand::Paths { .. } => Read,
        },
        Command::Policy { command } => match command {
            PolicyCommand::New { .. } => Write("policy new"),
            PolicyCommand::Status { .. } => Write("policy status"),
            PolicyCommand::List { .. }
            | PolicyCommand::Show { .. }
            | PolicyCommand::Paths { .. } => Read,
        },
        Command::Standard { command } => match command {
            StandardCommand::New { .. } => Write("standard new"),
            StandardCommand::Status { .. } => Write("standard status"),
            StandardCommand::List { .. }
            | StandardCommand::Show { .. }
            | StandardCommand::Paths { .. } => Read,
        },
        Command::Rfc { command } => match command {
            RfcCommand::New { .. } => Write("rfc new"),
            RfcCommand::Status { .. } => Write("rfc status"),
            RfcCommand::List { .. } | RfcCommand::Show { .. } | RfcCommand::Paths { .. } => Read,
        },
        Command::Spec { command } => match command {
            SpecCommand::New { .. } => Write("spec new"),
            SpecCommand::Req { command } => match command {
                SpecReqCommand::Add { .. } => Write("spec req add"),
                SpecReqCommand::Status { .. } => Write("spec req status"),
                // Read-only authored roster (design §5.3).
                SpecReqCommand::List { .. } => Read,
            },
            SpecCommand::Interactions { .. } => Write("spec interactions"),
            SpecCommand::Edit { .. } => Write("spec edit"),
            SpecCommand::List { .. }
            | SpecCommand::Show { .. }
            | SpecCommand::Validate { .. }
            | SpecCommand::Paths { .. } => Read,
        },
        // Export is read-only (RO proof): load + serialize, no mutation path.
        Command::Export { command } => match command {
            ExportCommand::Lazyspec { .. } => Read,
        },
        Command::Backlog { command } => match command {
            BacklogCommand::New { .. } => Write("backlog new"),
            BacklogCommand::Edit { .. } => Write("backlog edit"),
            BacklogCommand::Needs { .. } => Write("backlog needs"),
            BacklogCommand::After { .. } => Write("backlog after"),
            BacklogCommand::Tag { .. } => Write("backlog tag"),
            BacklogCommand::List { .. }
            | BacklogCommand::Show { .. }
            | BacklogCommand::Inspect { .. }
            | BacklogCommand::Paths { .. } => Read,
        },
        Command::Knowledge { command } => match command {
            KnowledgeCommand::New { .. } => Write("knowledge new"),
            KnowledgeCommand::Status { .. } => Write("knowledge status"),
            KnowledgeCommand::List { .. }
            | KnowledgeCommand::Show { .. }
            | KnowledgeCommand::Inspect { .. }
            | KnowledgeCommand::Paths { .. } => Read,
        },
        Command::Tag { .. } => Write("tag"),
        // The reservation survey only fetches + reads refs — no authored write.
        Command::Reservation { command } => match command {
            ReservationCommand::List { .. } => Read,
        },
        Command::Serve { .. } => Read,
        Command::Boot { command, .. } => match command {
            None => Write("boot"),
            Some(BootCommand::Install { .. }) => Write("boot install"),
        },
        Command::Worktree { command } => match command {
            // Provision/check-allowlist write *fork* files, not the doctrine state
            // the guard protects, and never run in worker context (§5.2) — Read.
            // branch-point-check is a HEAD read + ref compare — no authored write,
            // callable under worker-mode by construction (§5.2, C-V).
            // status reads the resolved mode (SL-056 §3) — open to workers.
            // verify-worker is a HEAD read + marker probe + is-ancestor compare on
            // the worker dir — no authored write, diagnostic only; harmless under
            // worker-mode (design §8.4/§8.6 lists no impersonation test for it).
            // pretooluse is the claude `PreToolUse` hook verb (SL-182 PHASE-03) —
            // it reads stdin + git topology and emits a decision, writing NO
            // authored state. It fires INSIDE the confined subagent (worker
            // context) on every tool call, so it MUST be open under worker-mode —
            // Read.
            // list is the worktree inventory verb (SL-190 PHASE-05) — a
            // read-only enumeration + landed probe, no authored write; open to
            // workers.
            WorktreeCommand::Provision { .. }
            | WorktreeCommand::CheckAllowlist { .. }
            | WorktreeCommand::BranchPointCheck { .. }
            | WorktreeCommand::VerifyWorker { .. }
            | WorktreeCommand::Pretooluse
            | WorktreeCommand::Status { .. }
            | WorktreeCommand::List { .. } => Read,
            // fork creates an orchestrator-owned worktree (SL-056 PHASE-06) — the
            // first Orchestrator-classed verb; refused under worker-mode.
            WorktreeCommand::Fork { .. } => Orchestrator("fork"),
            // create-fork is the claude `WorktreeCreate` hook verb (SL-152) — it
            // fires in the MARKERLESS parent coord tree (process cwd), so the
            // worker_guard resolves non-worker mode and it is allowed; a spawn from
            // inside a marked fork is refused fail-closed (acceptable — workers carry
            // no Agent tool). Orchestrator and Hookmint are functionally identical
            // under worker_guard; Orchestrator is the plan-locked class (G8).
            WorktreeCommand::CreateFork => Orchestrator("create-fork"),
            // coordinate creates/resumes the orchestrator's OWN coordination
            // worktree (SL-064 §2) — markerless, but still an orchestrator funnel
            // operation; refused under worker-mode via the SAME guard as fork (EX-4).
            WorktreeCommand::Coordinate { .. } => Orchestrator("coordinate"),
            // import lands a worker delta into the coordination index (SL-056
            // PHASE-07) — Orchestrator-classed; refused under worker-mode.
            WorktreeCommand::Import { .. } => Orchestrator("import"),
            // land lands a solo fork onto the coordination branch via --no-ff merge
            // (SL-056 PHASE-08) — Orchestrator-classed; refused under worker-mode.
            WorktreeCommand::Land { .. } => Orchestrator("land"),
            // gc reaps a spent worktree fork once provably landed (SL-056 PHASE-09)
            // — Orchestrator-classed; refused under worker-mode.
            WorktreeCommand::Gc { .. } => Orchestrator("gc"),
            // jail-prefix computes a worker's confinement wrap prefix for the
            // subprocess spawn arm (SL-185) — an orchestrator spawn-support op. The
            // design mandates the prefix is ORCHESTRATOR-computed (no worker
            // influences its own policy), so it is Orchestrator-classed and refused
            // under worker-mode.
            WorktreeCommand::JailPrefix { .. } => Orchestrator("jail-prefix"),
            // marker --stamp-subagent is the claude harness spawn path's provision+mark
            // step (SL-056 PHASE-10) — Hookmint, refused under worker-mode (the
            // legit first stamp lands on a marker-absent worktree ⇒ allowed). All
            // other marker forms (--clear, bare) are the bespoke self-brick cure —
            // NOT refused by the worker-mode guard; their fences live in the handler.
            WorktreeCommand::Marker {
                stamp_subagent: true,
                ..
            } => Hookmint("marker --stamp-subagent"),
            WorktreeCommand::Marker { .. } => MarkerClear,
        },
        // dispatch sync projects coordination refs (SL-064 PHASE-04 / ADR-012
        // §4) — Orchestrator-classed across the whole verb class; refused under
        // worker-mode via the SAME guard as coordinate/fork (EX-1).
        Command::Dispatch { command } => match command {
            DispatchCommand::Sync { .. } => Orchestrator("dispatch-sync"),
            DispatchCommand::RecordBoundary { .. } => Orchestrator("dispatch-record-boundary"),
            DispatchCommand::RefreshBase { .. } => Orchestrator("dispatch-refresh-base"),
            DispatchCommand::Setup { .. } => Orchestrator("dispatch-setup"),
            // arm-spawn writes the coord tree's arming base file (SL-152 PHASE-03,
            // sole-writer) — Orchestrator-classed; refused under worker-mode.
            DispatchCommand::ArmSpawn { .. } => Orchestrator("dispatch-arm-spawn"),
            // candidate create publishes coordination refs + ledger rows (SL-068
            // §5.3) — Orchestrator-classed like sync/record-boundary; refused
            // under worker-mode.
            DispatchCommand::Candidate { command } => match command {
                CandidateCommand::Create { .. } => Orchestrator("dispatch-candidate-create"),
                // candidate status is a read-only self-describing surface (SL-068
                // PHASE-04) — Read-classed so it works under worker-mode; it
                // mutates no ref and no ledger row.
                CandidateCommand::Status { .. } => Read,
                // candidate admit pins an immutable OID into candidates.toml
                // (SL-068 PHASE-05) — Orchestrator-classed like create; refused
                // under worker-mode.
                CandidateCommand::Admit { .. } => Orchestrator("dispatch-candidate-admit"),
            },
            // plan-next / status — read plan + phase sheets; never mutates a
            // ref or ledger row — Read-classed so it works under worker-mode.
            DispatchCommand::PlanNext { .. }
            | DispatchCommand::Status { .. }
            | DispatchCommand::DeliverTo { .. } => Read,
        },
        // The coverage group splits per inner verb (SL-057 D2a): `show` is the
        // read-only drift view; `record`/`forget` mutate the observed store, and
        // `verify` re-derives + saves per slice — all authored writes.
        Command::Coverage { command } => match command {
            CoverageCommand::Show { .. } => Read,
            CoverageCommand::Record { .. } => Write("coverage record"),
            CoverageCommand::Verify { .. } => Write("coverage verify"),
            CoverageCommand::Forget { .. } => Write("coverage forget"),
        },
        // Read-only: the corpus integrity scan (INV-3), and the cross-kind relation
        // view (SL-046 — reads only, never mints/derives status).
        // Read-only priority surfaces (SL-047 — derive per query, never write /
        // mint / derive status; ADR-004 stores no reverse field).
        Command::Catalog { .. }
        | Command::Search { .. }
        | Command::Relation { .. }
        | Command::Validate { .. }
        | Command::Doctor { .. }
        | Command::Inspect { .. }
        | Command::Survey { .. }
        | Command::Next { .. }
        | Command::Blockers { .. }
        | Command::Explain { .. }
        | Command::Findings { .. }
        // The check proxy writes NO authored doctrine state; a proxied command
        // that mutates source (e.g. `cargo fmt`) is a worker-legal source delta,
        // not an authored write — and a worker running `check gate` to verify its
        // fork is the intended use, so Read is correct AND necessary (SL-163 §5.3).
        // prompt resolve/model-keys/explain/check are all read-only (SL-186).
        | Command::Prompt { .. }
        | Command::Check { .. }
        | Command::Status { .. } => Read,
        // Mutates the canonical-id triple — an authored write (D2/D6).
        Command::Reseat { .. } => Write("reseat"),
        // Author / remove a tier-1 `[[relation]]` edge — authored writes (SL-048 §5.4).
        Command::Link { .. } => Write("link"),
        Command::Config { command } => match command {
            ConfigCommand::Set { .. } | ConfigCommand::Unset { .. } => Write("config"),
            ConfigCommand::Show { .. } | ConfigCommand::Get { .. } | ConfigCommand::Validate => {
                Read
            }
        },
        Command::Unlink { .. } => Write("unlink"),
        // Author a dep/seq edge into `[relationships]` — authored writes (SL-060 §5.4).
        Command::Needs { .. } => Write("needs"),
        Command::After { .. } => Write("after"),
        // Record a supersession — writes NEW.supersedes, OLD.superseded_by, OLD.status
        // in one transaction (SL-062 §5.4).
        Command::Supersede { .. } => Write("supersede"),
        // Estimate / Value / Risk facet writes (SL-118 PHASE-03, SL-134 PHASE-02).
        Command::Estimate { .. } => Write("estimate"),
        Command::Value { .. } => Write("value"),
        Command::Risk { .. } => Write("risk"),
    }
}

/// Worker-mode guard (ADR-006 D2a / SL-056 §3): refuse a Write-classed verb when
/// the cwd tree resolves to worker mode (marker in a linked worktree OR the
/// `DOCTRINE_WORKER` env optimisation). Read / `MarkerClear` pass through. The
/// marker leg is evaluated LAZILY — only a Write verb resolves the root, so a Read
/// verb in a non-doctrine cwd never gains a new failure path (design §3).
pub(crate) fn worker_guard(cmd: &Command) -> anyhow::Result<()> {
    // Write and Orchestrator are both refused under worker-mode with the SAME
    // branches; Read and the bespoke MarkerClear pass through (SL-056 PHASE-06).
    let verb = match write_class(cmd) {
        WriteClass::Write(verb) | WriteClass::Orchestrator(verb) | WriteClass::Hookmint(verb) => {
            verb
        }
        WriteClass::Read | WriteClass::MarkerClear => return Ok(()),
    };
    // No doctrine/project root above the cwd: the marker leg cannot apply. Fall
    // back to the env leg alone (a leaked env on a rootless cwd), never a new error.
    let Ok(root) = crate::root::find(None, &crate::root::default_markers()) else {
        if crate::worktree::env_worker_set() {
            anyhow::bail!(
                "{}: refusing authored write `{verb}`",
                crate::worktree::DUAL_CAUSE
            );
        }
        return Ok(());
    };
    let mode = crate::worktree::resolve_mode(&root);
    if !mode.refused {
        return Ok(());
    }
    // The env leg on a NON-linked tree carries the NAMED dual-cause message (never
    // a bare "worker refused"); the marker / linked-fork legs name the verb plainly.
    if mode.is_env_on_nonlinked() {
        anyhow::bail!(
            "{}: refusing authored write `{verb}`",
            crate::worktree::DUAL_CAUSE
        );
    }
    anyhow::bail!(
        "worker fork (signal: {}): refusing authored write `{verb}` — workers return a source delta; doctrine-mediated writes funnel through the orchestrator.",
        mode.cause_token()
    );
}