aion-server 0.27.1

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
use std::ffi::OsStr;
use std::io;
use std::path::{Component, Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::filesystem::ConfinedDir;

#[derive(Debug, Serialize)]
pub struct DocumentEntry {
    pub path: String,
    pub name: String,
}

#[derive(Debug, Serialize)]
pub struct DocumentResponse {
    pub source: String,
    pub content_hash: String,
}

#[derive(Debug, Deserialize)]
pub struct CreateDocumentRequest {
    pub name: String,
    /// Initial source for the new document, stored verbatim when present.
    ///
    /// When omitted the server synthesises a placeholder workflow named after
    /// the document. The content is deliberately NOT checked here — parity
    /// with `PUT /awl/documents/{path}`, whose saved document may also be
    /// invalid; the editor's check surface owns validity.
    #[serde(default)]
    pub source: Option<String>,
}

#[derive(Debug, Serialize)]
pub struct CreateDocumentResponse {
    pub path: String,
    pub name: String,
    pub source: String,
    pub content_hash: String,
}

#[derive(Debug, Deserialize)]
pub struct PutDocumentRequest {
    pub source: String,
}

#[derive(Debug, thiserror::Error)]
pub enum DocumentError {
    #[error("invalid AWL document path: {0}")]
    InvalidPath(String),
    #[error("invalid AWL document name: {0}")]
    InvalidName(String),
    #[error("AWL document was not found: {0}")]
    NotFound(String),
    #[error("AWL document already exists: {0}")]
    Exists(String),
    #[error("AWL workspace is not configured")]
    WorkspaceUnconfigured,
    #[error("AWL workspace I/O failed: {0}")]
    Io(#[from] io::Error),
    /// A document was written but its first revision could not be stored, and
    /// the half-created document could not be removed again. Both causes are
    /// carried because the operator needs the second one to get unstuck: until
    /// `path` is deleted by hand, creating that document will keep failing as
    /// though it already exists.
    #[error(
        "AWL document `{path}` was created but its first revision could not be stored ({reason}), \
         and removing the half-created document then failed ({rollback}); \
         `{path}` is still in the workspace and must be deleted before it can be created again"
    )]
    CreateRollbackFailed {
        path: String,
        reason: String,
        rollback: io::Error,
    },
}

impl DocumentError {
    /// The stable wire `error_type` label for this failure class.
    ///
    /// One vocabulary in one place: the HTTP studio routes and the MCP
    /// authoring tools both label their refusals through this, so a caller
    /// branching on `error_type` sees the same word whichever surface it used.
    #[must_use]
    pub fn error_type(&self) -> &'static str {
        match self {
            Self::InvalidPath(_) => "InvalidDocumentPath",
            Self::InvalidName(_) => "InvalidDocumentName",
            Self::NotFound(_) => "DocumentNotFound",
            Self::Exists(_) => "DocumentExists",
            Self::WorkspaceUnconfigured => "AuthoringWorkspaceUnconfigured",
            Self::Io(_) => "DocumentIoError",
            Self::CreateRollbackFailed { .. } => "DocumentCreateRollbackFailed",
        }
    }

    /// The wire form of this failure: code class and `error_type` together.
    ///
    /// The class mapping lives HERE, next to the variants, so every surface
    /// that carries a document refusal — the run-loop deploy path and the MCP
    /// tools alike — answers with the same code for the same failure.
    #[must_use]
    pub fn to_wire_error(&self) -> aion_proto::WireError {
        let wire = match self {
            Self::NotFound(_) => aion_proto::WireError::not_found(self.to_string()),
            Self::InvalidPath(_) | Self::InvalidName(_) | Self::Exists(_) => {
                aion_proto::WireError::invalid_input(self.to_string())
            }
            Self::WorkspaceUnconfigured | Self::Io(_) | Self::CreateRollbackFailed { .. } => {
                aion_proto::WireError::backend(self.to_string())
            }
        };
        wire.with_error_type(self.error_type())
    }
}

pub async fn list(root: &Path) -> Result<Vec<DocumentEntry>, DocumentError> {
    let root = root.to_owned();
    blocking("document listing", move || {
        let workspace = match ConfinedDir::open(&root) {
            Ok(workspace) => workspace,
            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
            Err(error) => return Err(DocumentError::Io(error)),
        };
        let mut entries: Vec<_> = workspace
            .list_awl()?
            .into_iter()
            .map(|path| DocumentEntry {
                name: path
                    .file_stem()
                    .unwrap_or(OsStr::new(""))
                    .to_string_lossy()
                    .into_owned(),
                path: path.to_string_lossy().replace('\\', "/"),
            })
            .collect();
        entries.sort_by(|left, right| left.path.cmp(&right.path));
        Ok(entries)
    })
    .await
}

pub async fn read(root: &Path, requested: &str) -> Result<DocumentResponse, DocumentError> {
    let relative = document_path(requested)?;
    let root = root.to_owned();
    let requested = requested.to_owned();
    let source = blocking("document read", move || {
        // A workspace root that does not exist yet is the module's normal
        // fresh-server state (nothing creates the configured directory until
        // the first write), and a document in a workspace that does not exist
        // is exactly as not-found as a document missing from one that does.
        // Only ENOENT takes this branch; EACCES/ELOOP/ENOTDIR still surface
        // as the I/O faults they are.
        let workspace = match ConfinedDir::open(&root) {
            Ok(workspace) => workspace,
            Err(error) if error.kind() == io::ErrorKind::NotFound => {
                return Err(DocumentError::NotFound(requested));
            }
            Err(error) => return Err(DocumentError::Io(error)),
        };
        workspace.read_to_string(&relative).map_err(|error| {
            if error.kind() == io::ErrorKind::NotFound {
                DocumentError::NotFound(requested)
            } else {
                confinement_error(error)
            }
        })
    })
    .await?;
    Ok(DocumentResponse {
        content_hash: super::revisions::content_hash(&source),
        source,
    })
}

pub async fn create(
    root: &Path,
    request: CreateDocumentRequest,
) -> Result<CreateDocumentResponse, DocumentError> {
    validate_document_name(&request.name)?;
    let CreateDocumentRequest { name, source } = request;
    let source = match source {
        Some(source) => source,
        None => new_document_source(&name)?,
    };
    let path = format!("{name}.awl");
    let root_owned = root.to_owned();
    let path_owned = PathBuf::from(&path);
    let source_owned = source.clone();
    blocking("document create", move || {
        let workspace = ConfinedDir::open_or_create(&root_owned).map_err(confinement_error)?;
        workspace
            .create_new(&path_owned, source_owned.as_bytes())
            .map_err(|error| {
                if error.kind() == io::ErrorKind::AlreadyExists {
                    DocumentError::Exists(path_owned.to_string_lossy().into_owned())
                } else {
                    confinement_error(error)
                }
            })
    })
    .await?;
    let revision = match super::revisions::store(root, &source).await {
        Ok(revision) => revision,
        Err(error) => {
            // The document file is already on disk but has no first revision,
            // so it must come back off. If the rollback ITSELF fails the
            // operator has to hear about it: the orphan stays in the
            // workspace, and every retry of this create will now fail with
            // `Exists` for a document that was never successfully created.
            // Reporting only the revision error would send them chasing that
            // instead of the file they actually have to delete.
            if let Err(rollback) = rollback_created_document(root, &path).await {
                return Err(DocumentError::CreateRollbackFailed {
                    path: path.clone(),
                    reason: error.to_string(),
                    rollback,
                });
            }
            return Err(revision_io(&error));
        }
    };
    Ok(CreateDocumentResponse {
        path,
        name,
        source,
        content_hash: revision.content_hash,
    })
}

pub async fn write(
    root: &Path,
    requested: &str,
    request: PutDocumentRequest,
) -> Result<DocumentResponse, DocumentError> {
    let relative = document_path(requested)?;
    let root_owned = root.to_owned();
    let source_owned = request.source.clone();
    blocking("document write", move || {
        let workspace = ConfinedDir::open_or_create(&root_owned).map_err(confinement_error)?;
        workspace
            .atomic_write(&relative, source_owned.as_bytes())
            .map_err(confinement_error)
    })
    .await?;
    let revision = super::revisions::store(root, &request.source)
        .await
        .map_err(|error| revision_io(&error))?;
    Ok(DocumentResponse {
        source: request.source,
        content_hash: revision.content_hash,
    })
}

pub(crate) fn document_path(requested: &str) -> Result<PathBuf, DocumentError> {
    let path = Path::new(requested);
    if requested.is_empty()
        || path
            .components()
            .any(|component| !matches!(component, Component::Normal(_)))
    {
        return Err(DocumentError::InvalidPath(
            "path must be non-empty, relative, and contain no `..` components".to_owned(),
        ));
    }
    if path.extension() != Some(OsStr::new("awl")) {
        return Err(DocumentError::InvalidPath(
            "document path must end in `.awl`".to_owned(),
        ));
    }
    Ok(path.to_owned())
}

/// Remove a document that was created but never got a first revision.
///
/// Runs on the blocking pool like every other filesystem call in this module —
/// the original rollback ran `ConfinedDir::open` and `remove_file` inline on
/// the async runtime, which blocks a reactor thread on real disk I/O.
///
/// A workspace that has already vanished is treated as a successful rollback:
/// the document it held is gone too, which is the outcome this is for.
async fn rollback_created_document(root: &Path, path: &str) -> Result<(), io::Error> {
    let root_owned = root.to_owned();
    let path_owned = PathBuf::from(path);
    tokio::task::spawn_blocking(move || {
        let workspace = match ConfinedDir::open(&root_owned) {
            Ok(workspace) => workspace,
            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
            Err(error) => return Err(error),
        };
        match workspace.remove_file(&path_owned) {
            Ok(()) => Ok(()),
            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
            Err(error) => Err(error),
        }
    })
    .await
    .map_err(|error| io::Error::other(format!("document rollback task failed: {error}")))?
}

fn confinement_error(error: io::Error) -> DocumentError {
    if matches!(
        error.kind(),
        io::ErrorKind::InvalidInput | io::ErrorKind::NotADirectory
    ) {
        DocumentError::InvalidPath(format!(
            "workspace paths must contain only real directories and files: {error}"
        ))
    } else {
        DocumentError::Io(error)
    }
}

async fn blocking<T: Send + 'static>(
    operation: &'static str,
    work: impl FnOnce() -> Result<T, DocumentError> + Send + 'static,
) -> Result<T, DocumentError> {
    tokio::task::spawn_blocking(work)
        .await
        .map_err(|error| io::Error::other(format!("{operation} task failed: {error}")))?
}

fn revision_io(error: &super::revisions::RevisionError) -> DocumentError {
    DocumentError::Io(io::Error::other(error.to_string()))
}

/// A document name must be a name the LANGUAGE can hold, not merely a name a
/// filesystem can hold.
///
/// The rule here is the AWL lexer's rule, and it is stated where the lexer
/// states it: a word begins on an ASCII lowercase letter
/// (`lexer/cursor.rs:100`) and continues through letters, digits and
/// underscores (`is_identifier_continue`, `lexer/cursor.rs:256`). Anything
/// else at the first character is a `stray character` lex error.
///
/// The start rule is load-bearing because create-with-source deliberately
/// does not parse the bytes it stores — this validation is the ONLY guard on
/// that path — so a name the lexer refuses would otherwise reach the
/// workspace as a real document that no `workflow <name>` declaration can
/// ever name.
fn validate_document_name(name: &str) -> Result<(), DocumentError> {
    let mut characters = name.chars();
    let starts_validly = characters.next().is_some_and(|character| {
        // Not `_`: the lexer opens a word on a letter only.
        character.is_ascii_lowercase()
    });
    if !starts_validly
        || !characters.all(|character| {
            character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
        })
    {
        return Err(DocumentError::InvalidName(
            "use a lowercase AWL identifier: start with a lowercase letter, then letters, digits \
             and underscores"
                .to_owned(),
        ));
    }
    Ok(())
}

fn new_document_source(name: &str) -> Result<String, DocumentError> {
    let source = format!(
        "//! {name} workflow.\nworkflow {name}\n  outcome done: type Placeholder, route success\n\ntype Placeholder {{ value: String }}\n"
    );
    let document =
        aion_awl::parse(&source).map_err(|error| DocumentError::InvalidName(error.message))?;
    Ok(aion_awl::print(&document))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn missing_workspace_lists_empty_without_materializing_it()
    -> Result<(), Box<dyn std::error::Error>> {
        let parent = crate::test_support::private_tempdir()?;
        let workspace = parent.path().join("aion-authoring");
        assert!(list(&workspace).await?.is_empty());
        assert!(!workspace.exists());
        Ok(())
    }

    /// The read twin of the listing test above: on a fresh server the
    /// configured workspace directory does not exist until the first write,
    /// and a read against that absence is `NotFound` — the same refusal, with
    /// the same guidance downstream, as a document missing from a workspace
    /// that does exist — never a raw ENOENT dressed as an I/O fault. And the
    /// read must not materialize the directory as a side effect.
    #[tokio::test]
    async fn missing_workspace_reads_not_found_without_materializing_it()
    -> Result<(), Box<dyn std::error::Error>> {
        let parent = crate::test_support::private_tempdir()?;
        let workspace = parent.path().join("never-created");
        let refusal = read(&workspace, "any.awl").await;
        assert!(
            matches!(refusal, Err(DocumentError::NotFound(ref path)) if path == "any.awl"),
            "expected NotFound(any.awl), got {refusal:?}"
        );
        assert!(!workspace.exists(), "the read materialized the workspace");
        Ok(())
    }

    #[tokio::test]
    async fn workspace_round_trip_rejects_traversal() -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        write(
            workspace.path(),
            "nested/example.awl",
            PutDocumentRequest {
                source: "workflow example\n".to_owned(),
            },
        )
        .await?;
        let entries = list(workspace.path()).await?;
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0].path, "nested/example.awl");
        assert_eq!(
            read(workspace.path(), "nested/example.awl").await?.source,
            "workflow example\n"
        );
        assert!(matches!(
            write(
                workspace.path(),
                "../outside.awl",
                PutDocumentRequest {
                    source: String::new()
                }
            )
            .await,
            Err(DocumentError::InvalidPath(_))
        ));
        assert!(matches!(
            read(workspace.path(), "/tmp/outside.awl").await,
            Err(DocumentError::InvalidPath(_))
        ));
        Ok(())
    }

    /// A create whose first revision cannot be stored must leave NOTHING
    /// behind. The rollback used to be skippable in silence, and the cost of
    /// skipping it is not a stray file — it is that every retry of the same
    /// create then fails with `Exists` for a document the operator never
    /// managed to create, with nothing anywhere saying why.
    #[tokio::test]
    async fn a_create_whose_revision_fails_leaves_no_document_behind()
    -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        // `.aion-authoring` is where revisions live. As a regular file it
        // cannot be opened as a directory, so storing the first revision
        // fails after the document itself is already on disk — exactly the
        // window the rollback exists to close.
        std::fs::write(workspace.path().join(".aion-authoring"), b"not a directory")?;

        let failure = create(
            workspace.path(),
            CreateDocumentRequest {
                name: "rollback_probe".to_owned(),
                source: None,
            },
        )
        .await;
        assert!(
            failure.is_err(),
            "a create whose revision cannot be stored must not report success"
        );
        assert!(
            !workspace.path().join("rollback_probe.awl").exists(),
            "the half-created document was left in the workspace"
        );
        assert!(
            list(workspace.path()).await?.is_empty(),
            "the half-created document is still listed"
        );

        // With the revision store repaired, the same name must be creatable —
        // proving the failed attempt did not poison it.
        std::fs::remove_file(workspace.path().join(".aion-authoring"))?;
        let created = create(
            workspace.path(),
            CreateDocumentRequest {
                name: "rollback_probe".to_owned(),
                source: None,
            },
        )
        .await?;
        assert_eq!(created.path, "rollback_probe.awl");
        Ok(())
    }

    /// A rollback that cannot do its job must SAY SO. This is the exact
    /// defect: the old code wrote `if let Ok(workspace) = …` and
    /// `let _ = workspace.remove_file(…)`, so a rollback that failed was
    /// indistinguishable from one that worked, and the operator was told only
    /// about the revision error while the orphan sat in the workspace
    /// poisoning every retry.
    #[cfg(unix)]
    #[tokio::test]
    async fn a_rollback_that_cannot_remove_the_document_reports_it()
    -> Result<(), Box<dyn std::error::Error>> {
        use std::os::unix::fs::PermissionsExt;

        // Root bypasses directory permissions, so the sabotage below would not
        // sabotage anything and the assertion would pass vacuously. Skip
        // loudly at runtime rather than hiding the test behind `#[ignore]`.
        if rustix::process::geteuid().is_root() {
            tracing::info!(
                "skipping rollback-failure pin: running as root, which bypasses the \
                 directory permissions this test depends on"
            );
            return Ok(());
        }

        let workspace = crate::test_support::private_tempdir()?;
        let document = workspace.path().join("stuck.awl");
        std::fs::write(&document, b"workflow stuck\n")?;

        // Owner read+execute only: the document can still be seen but not
        // unlinked, because unlinking needs write on the DIRECTORY.
        std::fs::set_permissions(workspace.path(), std::fs::Permissions::from_mode(0o500))?;
        let outcome = rollback_created_document(workspace.path(), "stuck.awl").await;
        std::fs::set_permissions(workspace.path(), std::fs::Permissions::from_mode(0o700))?;

        let error = match outcome {
            Ok(()) => {
                return Err(
                    "a rollback that could not remove the document reported success — \
                            this is the swallowed error the fix exists to stop"
                        .into(),
                );
            }
            Err(error) => error,
        };
        assert_eq!(
            error.kind(),
            io::ErrorKind::PermissionDenied,
            "the rollback failure must carry its real cause, got: {error}"
        );
        assert!(document.exists(), "the test did not actually block removal");
        Ok(())
    }

    /// The rollback must not invent a failure when there is nothing to undo.
    #[tokio::test]
    async fn rolling_back_a_vanished_workspace_is_not_a_failure()
    -> Result<(), Box<dyn std::error::Error>> {
        let parent = crate::test_support::private_tempdir()?;
        let absent = parent.path().join("never-created");
        rollback_created_document(&absent, "anything.awl").await?;

        let workspace = crate::test_support::private_tempdir()?;
        rollback_created_document(workspace.path(), "never-written.awl").await?;
        Ok(())
    }

    #[tokio::test]
    async fn create_is_atomic_typed_and_private() -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        let created = create(
            workspace.path(),
            CreateDocumentRequest {
                name: "first_workflow".to_owned(),
                source: None,
            },
        )
        .await?;
        assert_eq!(created.path, "first_workflow.awl");
        assert_eq!(
            aion_awl::print(&aion_awl::parse(&created.source)?),
            created.source
        );
        assert!(matches!(
            create(
                workspace.path(),
                CreateDocumentRequest {
                    name: "first_workflow".to_owned(),
                    source: None,
                }
            )
            .await,
            Err(DocumentError::Exists(_))
        ));
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mode = std::fs::metadata(workspace.path().join(&created.path))?
                .permissions()
                .mode();
            assert_eq!(mode & 0o777, 0o600);
        }
        Ok(())
    }

    /// A create carrying its own source stores those bytes VERBATIM — no
    /// placeholder synthesis, no canonical reprint, no validation. The guide's
    /// "Try it" flow depends on the created document being the example the
    /// author just read, byte for byte.
    #[tokio::test]
    async fn a_create_with_source_stores_the_bytes_verbatim()
    -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        // Deliberately NOT canonically formatted (single-space indent) and
        // carrying a trailing comment: a reprint or a placeholder would both
        // change these bytes.
        let example = "//! Guide example.\nworkflow guide_example\n outcome done: type D, route success\n\ntype D { value: String }\n// tail\n";
        let created = create(
            workspace.path(),
            CreateDocumentRequest {
                name: "guide_example".to_owned(),
                source: Some(example.to_owned()),
            },
        )
        .await?;
        assert_eq!(created.path, "guide_example.awl");
        assert_eq!(created.name, "guide_example");
        assert_eq!(created.source, example);
        assert_eq!(
            read(workspace.path(), "guide_example.awl").await?.source,
            example
        );
        assert_eq!(
            created.content_hash,
            super::super::revisions::content_hash(example)
        );

        // Same atomic `create_new` refusal as the placeholder path: a second
        // create of the same name is `Exists`, never an overwrite.
        assert!(matches!(
            create(
                workspace.path(),
                CreateDocumentRequest {
                    name: "guide_example".to_owned(),
                    source: Some("other bytes\n".to_owned()),
                }
            )
            .await,
            Err(DocumentError::Exists(_))
        ));
        assert_eq!(
            read(workspace.path(), "guide_example.awl").await?.source,
            example
        );
        Ok(())
    }

    /// Parity with `PUT`: the stored source may be invalid AWL. Create does
    /// not check content — the editor's check surface owns validity — and the
    /// name validation still guards the path.
    #[tokio::test]
    async fn a_create_with_source_accepts_unchecked_bytes_but_still_validates_the_name()
    -> Result<(), Box<dyn std::error::Error>> {
        let workspace = crate::test_support::private_tempdir()?;
        let created = create(
            workspace.path(),
            CreateDocumentRequest {
                name: "unchecked_bytes".to_owned(),
                source: Some("definitely not awl\n".to_owned()),
            },
        )
        .await?;
        assert_eq!(created.source, "definitely not awl\n");
        assert!(matches!(
            create(
                workspace.path(),
                CreateDocumentRequest {
                    name: "Bad-Name".to_owned(),
                    source: Some("workflow ok\n".to_owned()),
                }
            )
            .await,
            Err(DocumentError::InvalidName(_))
        ));
        assert!(!workspace.path().join("Bad-Name.awl").exists());
        Ok(())
    }

    /// Whether AWL itself will hold a document declared under this name.
    ///
    /// The oracle is the CHECKER, not the parser, and that distinction is
    /// measured rather than assumed: `workflow Bad` parses (the lexer reads
    /// `Bad` as a type identifier and the parser takes it), and it is the
    /// checker that refuses a workflow name which is not `snake_case` —
    /// `aion-awl/tests/fixtures/rev2/header-types/invalid/`
    /// `workflow_name_not_snake_case.awl` is that rule's own fixture. A pin
    /// written against the parser alone would have declared the validator
    /// wrong about every capitalised name.
    fn the_language_holds_a_document_named(name: &str) -> bool {
        let Ok(source) = new_document_source(name) else {
            return false;
        };
        let Ok(document) = aion_awl::parse(&source) else {
            return false;
        };
        aion_awl::check(&document).is_empty()
    }

    /// The name validator answers the same question the LANGUAGE answers.
    ///
    /// This is the pin for #153: the start rule used to admit a leading
    /// underscore, which the AWL lexer refuses outright (`stray character`),
    /// so a create-with-source — the path that deliberately stores unchecked
    /// bytes and leans on this validation alone — could put `_name.awl` in
    /// the workspace as a document no `workflow` declaration could ever name.
    ///
    /// Driving the language side off the real parse-and-check chain rather
    /// than off a literal expectation is what keeps the two rules from
    /// drifting apart again: widen either one alone and a row disagrees.
    #[test]
    fn the_accepted_name_set_is_exactly_the_set_the_language_can_declare() {
        // Both an accepted and a refused row are required: a validator that
        // refused everything, or a language check that accepted everything,
        // would agree vacuously on a one-sided table.
        for name in [
            "ok_name", "ok_1", "_leading", "Bad", "has-dash", "9start", "",
        ] {
            let validator_accepts = validate_document_name(name).is_ok();
            let language_accepts = the_language_holds_a_document_named(name);
            assert_eq!(
                validator_accepts, language_accepts,
                "`{name}`: the name validator and the AWL checker must agree — validator \
                 accepts {validator_accepts}, language accepts {language_accepts}"
            );
        }
        assert!(
            validate_document_name("ok_name").is_ok(),
            "the table must contain a name that is accepted, or the agreement above is vacuous"
        );
        assert!(
            the_language_holds_a_document_named("ok_name"),
            "the language side must accept something too, or its agreement is equally vacuous"
        );
        assert!(
            validate_document_name("_leading").is_err(),
            "a leading underscore is the case this pin exists for"
        );
    }

    /// The fresh-server arm: a create-with-source against a workspace
    /// directory that has never been created materializes it
    /// (`open_or_create`) and succeeds — the #184/#161 ENOENT discipline
    /// covers reads and listings, and CREATE is the write that ends it.
    #[tokio::test]
    async fn a_create_with_source_materializes_a_never_created_workspace()
    -> Result<(), Box<dyn std::error::Error>> {
        let parent = crate::test_support::private_tempdir()?;
        let workspace = parent.path().join("never-created");
        let created = create(
            &workspace,
            CreateDocumentRequest {
                name: "first_try".to_owned(),
                source: Some("//! First.\nworkflow first_try\n".to_owned()),
            },
        )
        .await?;
        assert_eq!(created.path, "first_try.awl");
        assert_eq!(
            read(&workspace, "first_try.awl").await?.source,
            "//! First.\nworkflow first_try\n"
        );
        Ok(())
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn root_parent_and_dangling_temp_links_cannot_escape()
    -> Result<(), Box<dyn std::error::Error>> {
        use std::os::unix::fs::symlink;

        let sandbox = crate::test_support::private_tempdir()?;
        let outside = sandbox.path().join("outside");
        std::fs::create_dir(&outside)?;
        let root_link = sandbox.path().join("root-link");
        symlink(&outside, &root_link)?;
        assert!(
            write(
                &root_link,
                "escape.awl",
                PutDocumentRequest {
                    source: "escaped".to_owned()
                }
            )
            .await
            .is_err()
        );
        assert!(!outside.join("escape.awl").exists());

        let workspace = sandbox.path().join("workspace");
        std::fs::create_dir(&workspace)?;
        crate::test_support::make_private(&workspace)?;
        symlink(&outside, workspace.join("linked"))?;
        assert!(
            write(
                &workspace,
                "linked/escape.awl",
                PutDocumentRequest {
                    source: "escaped".to_owned()
                }
            )
            .await
            .is_err()
        );
        assert!(!outside.join("escape.awl").exists());

        let victim = outside.join("victim");
        symlink(&victim, workspace.join(".victim.awl.aion-awl.tmp"))?;
        write(
            &workspace,
            "victim.awl",
            PutDocumentRequest {
                source: "safe".to_owned(),
            },
        )
        .await?;
        assert!(
            !victim.exists(),
            "predictable dangling temp link was followed"
        );
        Ok(())
    }
}