Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions decisions/decisions/adr-129-confined-rename-writes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
---
schema_version: 1
id: RAC-01K8Q7MCP407
type: decision
---
# ADR-129: Confine Rename Writes to the Corpus Root

## Context

The corpus walker deliberately includes symlinked Markdown files on read-only
surfaces for compatibility. A rename is different: `decided rename --apply`
turns those paths into write targets. Following a symlink there can modify a
file outside the corpus the operator supplied, and a path can also be swapped
between planning and replacement.

## Decision

The native rename engine treats the requested corpus root as a mutation
boundary.

- The root is canonicalized before a plan is built and again before a plan is
applied.
- Every target and relationship-bearing path in a plan must be a regular,
non-symlink path whose canonical destination remains below that root.
- A symlinked mutation path, an unresolvable path, or a path that resolves
outside the root produces a refused dry-run with a stable reason code and
the offending path; no file is written.
- Application repeats the containment and symlink checks immediately before
reading each file and immediately before replacing it.
- Unix staging opens each temporary final component with `O_NOFOLLOW` as a
final-component race guard; same-directory replacement uses the staged file
after the immediate root checks. Read-only discovery remains unchanged and
may still report symlinked Markdown files.

## Status

Accepted

## Category

Technical

## Consequences

Rename cannot silently write through a corpus symlink or escape the requested
root. A corpus that intentionally exposes a symlinked Markdown target must
materialize that file before renaming it; this is an explicit safety refusal,
not a partial edit. The extra metadata and canonicalization checks are bounded
by the number of files in the deterministic edit set.

The protection is deliberately narrow. It does not change read-only walk
parity, rename ordering, identity semantics, or the exact-line stale-plan
check. `O_NOFOLLOW` closes the final-component race while staging on Unix; the
immediate rechecks provide the same root-boundary policy on other platforms.

## Alternatives Considered

### Follow symlinks as the walker does

Rejected. Read compatibility is not authorization to mutate an arbitrary
target selected by a link.

### Silently skip symlinked files

Rejected. A skipped inbound reference would make a successful rename silently
incomplete. The dry-run must identify the path and refuse the whole plan.

### Rename through directory handles only

Rejected for this release. No-follow directory-handle APIs vary across the
supported platforms; canonical containment, immediate rechecks, and the Unix
final-component no-follow flag provide a deterministic cross-platform contract
without changing the CLI surface.

## Code Constraints

```yaml
version: 1
eligibility: eligible
reason: "Rename safety is a deterministic source-level boundary with no model judgement."
rules:
- id: rename-confines-mutation-paths
kind: require_pattern
path_glob: "rust/rac-engine/src/rename.rs"
pattern: "check_mutation_path"
message: "Rename must recheck every mutation path against the canonical corpus root."
- id: rename-no-follow-final-write
kind: require_pattern
path_glob: "rust/rac-engine/src/rename.rs"
pattern: "O_NOFOLLOW"
message: "Unix rename staging must refuse a final-component symlink race."
```

## Related Decisions

- adr-007
- adr-023
- adr-063
- adr-080
- adr-123

## Applies To

- rust/rac-engine/src/rename.rs
- rust/rac-engine/tests/rename.rs
- rust/PORT-CONTRACT.d/16-closure-scaffold-writes.md
- docs/cli.md
115 changes: 115 additions & 0 deletions decisions/decisions/adr-130-transactional-rename-application.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
schema_version: 1
id: RAC-01K8Q7MCP408
type: decision
---
# ADR-130: Transactional Rename Application

## Context

An artifact-id rename edits the target identity and every inbound reference.
Writing those files one at a time can leave the corpus half-renamed when a
later stale check, permission check, or filesystem replacement fails. A green
process exit must never hide a split identity/reference state.

## Decision

`decided rename --apply` uses a deterministic local transaction for all files in
the plan.

- Every affected file is read, checked for exact `old_line` staleness, and
rendered in memory before any corpus path is replaced.
- Each rendered result is written and flushed to a hidden sibling staging file
in the same directory. Staging uses exclusive creation; Unix opens the
temporary final component with `O_NOFOLLOW`.
- During commit, each original moves to a hidden sibling backup and its staged
replacement moves into the original path. Files are processed in the
plan's first-seen path order.
- Any backup, replacement, or containment failure rolls committed files back
in reverse order from their backups. A successful rollback says `corpus
restored`; an incomplete rollback is reported explicitly with the paths that
could not be recovered.
- Successful commits remove all staging and backup files. Cleanup failures are
reported as a committed-but-cleanup-incomplete result; they never masquerade
as a clean success.

The transaction remains bounded to the canonical root and the root-confined
mutation checks in ADR-129. Read-only walk behavior is unchanged.

## Status

Accepted

## Category

Technical

## Consequences

The identity and inbound references move together or the engine reports an
explicit failure. A later filesystem error can still make rollback impossible
if an external actor replaces a path during recovery, but the command reports
that condition rather than claiming success. Temporary siblings stay on the
same filesystem, so each rename operation is atomic at the individual-path
level and does not require a cross-volume coordination service.

The commit is intentionally not a database transaction: no filesystem-wide
multi-path atomic primitive exists across the supported platforms. Backups and
reverse-order restoration provide deterministic recovery within the corpus
boundary.

## Alternatives Considered

### Continue writing files sequentially in place

Rejected. A late stale or permission failure can leave references and identity
out of sync, which is precisely the integrity failure this decision closes.

### Stage files but do not retain backups

Rejected. Staging protects against a failure before commit, but cannot restore
files already replaced when a later rename fails.

### Use a database or filesystem snapshot

Rejected. The corpus is ordinary Markdown on filesystems with different
snapshot capabilities. Sibling backups preserve portability and keep the
mutation contract local and inspectable.

## Code Constraints

```yaml
version: 1
eligibility: eligible
reason: "Transactional rename ordering and rollback are deterministic filesystem behavior."
rules:
- id: rename-preflights-before-commit
kind: require_pattern
path_glob: "rust/rac-engine/src/rename.rs"
pattern: "PreparedRenameFile"
message: "Rename must render all affected files before replacing any corpus path."
- id: rename-stages-sibling-files
kind: require_pattern
path_glob: "rust/rac-engine/src/rename.rs"
pattern: "create_new"
message: "Rename staging must use exclusive sibling temporary files."
- id: rename-rolls-back-on-failure
kind: require_pattern
path_glob: "rust/rac-engine/src/rename.rs"
pattern: "rollback_transaction"
message: "Rename commit failures must attempt deterministic reverse-order recovery."
```

## Related Decisions

- adr-007
- adr-023
- adr-129

## Applies To

- rust/rac-engine/src/rename.rs
- rust/rac-engine/src/output.rs
- rust/rac-engine/tests/rename.rs
- rust/PORT-CONTRACT.d/16-closure-scaffold-writes.md
- docs/cli.md
14 changes: 12 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,14 +383,24 @@ decided rename ADR-001 ADR-099 decisions/ --json # the plan as a stable dict
the file, which is out of scope — so the rename refuses rather than leave `new-id`
dangling. It also refuses an `old-id` that is unknown or ambiguous, and a `new-id`
that is malformed or already names another artifact (which would create a duplicate
identity). Every refusal leaves the corpus untouched and exits `1`.
identity). The native engine additionally confines mutation to the canonical
corpus root (ADR-129): a Markdown symlink, an unresolvable edit path, or a path
that resolves outside the requested root is refused as `symlink-path` or
`path-outside-root`. The human dry run identifies the offending path; the
JSON plan carries it in `target_path`. Every refusal leaves the corpus
untouched and exits `1`.

**Guarantees.**

- **Deterministic** — the same inputs produce a byte-identical plan; edits are
ordered by path then line (ADR-002).
- **Reversible** — applying `rename <new> <old>` after a rename restores the
original bytes. No semantic inference happens anywhere.
- **Transactional** — every affected file is preflighted and staged before
replacement. Same-directory backups allow reverse-order rollback when a
later replacement fails; the command reports `corpus restored` or an
explicit incomplete-recovery error rather than silently leaving a partial
rename (ADR-130).
- **Clean afterwards** — after `--apply`, `decided relationships <dir> --validate` is
clean: every inbound reference resolves to the renamed artifact.

Expand All @@ -399,7 +409,7 @@ identity_field, files_changed, reference_edits, identity_edits, edits[] }`, wher
each edit is `{ path, line, old_line, new_line, kind }` (`kind` is `"reference"` or
`"identity"`). On refusal, `ok` is `false` and `reason` is one of the stable codes
`old-ref-not-found`, `old-ref-ambiguous`, `new-ref-invalid`, `new-ref-collides`,
`old-ref-filename-only`. The `--apply` result is `{ applied, old_ref, new_ref,
`old-ref-filename-only`, `symlink-path`, `path-outside-root`. The `--apply` result is `{ applied, old_ref, new_ref,
target_path, files_changed, reference_edits, identity_edits }`.

In the editor, **RAC: Rename artifact id** runs this dry run, shows the affected
Expand Down
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 17 additions & 2 deletions rust/PORT-CONTRACT.d/16-closure-scaffold-writes.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,8 @@ Refusal routing is split: human refusal → STDERR (`Rename <old> ->
<new>` / blank / `✗ Refused: <phrase>.`), JSON refusal → STDOUT (the
full plan with `ok:false` and a stable `reason` code: `old-ref-not-found`,
`old-ref-ambiguous`, `new-ref-collides` (target_path still set),
`new-ref-invalid`, `old-ref-filename-only`).
`new-ref-invalid`, `old-ref-filename-only`, `symlink-path`, or
`path-outside-root`).

Plan semantics (all pinned): `new_ref` is stripped, then must match
`^[A-Za-z][\w.-]*$` BEFORE any walk; `old_ref` resolves case-insensitively
Expand Down Expand Up @@ -202,7 +203,21 @@ and <I> identity edit across <F> file(s).`. Apply JSON is the
RenameResult (no `edits` array). `--apply` replaces exact lines
(verified against `old_line`) and preserves the file's final-newline
shape; the plan `directory` echoes the argv verbatim (trailing slash
kept) while edit paths are walk-normalized.
kept) while edit paths are walk-normalized. Mutation is confined to the
canonical corpus root (ADR-129): the target and every relationship-bearing
edit path must be a non-symlink regular path whose canonical destination is
under that root. A symlinked path or a path that cannot be resolved is a
whole-plan refusal with reason `symlink-path` or `path-outside-root`; the
human dry-run includes the offending path and JSON keeps it in
`target_path`. Apply repeats the checks immediately before each read and
replacement; Unix staging opens its final component with `O_NOFOLLOW` and
same-directory replacement uses the staged file. All affected files are
preflighted and staged before any replacement. Originals move to sibling
backups and a later failure triggers reverse-order rollback; the engine reports
`corpus restored` or an explicit `rollback incomplete` error. Successful
commits remove staging and backups; cleanup failures are reported rather than
silently ignored. Read-only discovery still yields symlinked Markdown files
for parity.

## 7. `rac migrate {metadata} <directory> [--dry-run] [--top-level] [--recursive] [--json]`

Expand Down
3 changes: 3 additions & 0 deletions rust/rac-engine/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ serde_yaml = "0.9"

[target.'cfg(target_os = "linux")'.dependencies]
inotify = { version = "0.11", default-features = false }

[target.'cfg(unix)'.dependencies]
libc = "0.2"
20 changes: 19 additions & 1 deletion rust/rac-engine/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3604,6 +3604,12 @@ fn rename_reason_phrase(reason: Option<&str>) -> String {
rewrite, and renaming files is out of scope"
.to_string()
}
Some(crate::rename::REASON_SYMLINK_PATH) => {
"one or more mutation paths are symlinks".to_string()
}
Some(crate::rename::REASON_PATH_OUTSIDE_ROOT) => {
"a mutation path is unresolved or outside the corpus root".to_string()
}
Some(other) => other.to_string(),
None => "unknown".to_string(),
}
Expand All @@ -3615,7 +3621,19 @@ pub fn render_rename_human(plan: &crate::rename::RenamePlan) -> String {
let header = format!("Rename {} -> {}", plan.old_ref, plan.new_ref);
if !plan.ok {
let reason = rename_reason_phrase(plan.reason);
return format!("{header}\n\n{}", red(&format!("\u{2717} Refused: {reason}.")));
let path = match plan.reason {
Some(crate::rename::REASON_SYMLINK_PATH)
| Some(crate::rename::REASON_PATH_OUTSIDE_ROOT) => plan
.target_path
.as_deref()
.map(|path| format!(" Path: {path}."))
.unwrap_or_default(),
_ => String::new(),
};
return format!(
"{header}\n\n{}",
red(&format!("\u{2717} Refused: {reason}.{path}"))
);
}
let mut lines = vec![header.clone(), "=".repeat(header.chars().count()), String::new()];
lines.push(format!(
Expand Down
Loading
Loading