patchapply

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: MIT Imports: 9 Imported by: 0

Documentation

Overview

Package patchapply implements atomic, content-based file mutation with rollback journaling for a single target directory, per ADR-0032 ("Agent-First Development Experience"), P2: "Add atomic patch application and rollback journaling" (Jira MOD-70). The ADR's "Safety and governance" section requires that the system:

preserve dirty-worktree state and unrelated edits

apply patches atomically and retain rollback information

and docs/planning/agent-safety-policy.md's "Dry runs and rollback" section states the same requirement in policy form:

Patches should be applied atomically: either a full logical change
lands, or none of it does.

This package is the concrete mechanism satisfying both.

Why "patchapply" and not diff-format parsing

"Patch" here means "a set of intended file mutations" — write new content to a path, or delete a path — expressed directly as a FileChange slice, NOT literal unified-diff/git-patch text. An agent (or any caller) that has already decided what a file's new content should be does not need this package to also parse a diff format to get there; it needs a safe place to land that content. Scoping this package to content-based changes keeps it a filesystem-transaction primitive with zero new dependencies (stdlib only, plus this repository's own github.com/mediusfy/modulex/approval and github.com/mediusfy/modulex/provenance) — exactly what the acceptance criteria below need, without diff-parsing complexity or a third-party library. A caller that starts from unified-diff text is expected to parse it into a []FileChange itself (or with a separate library of its choosing) before calling Apply.

The package is named for what it does — apply a patch (a change set) — not "diff" (it never parses diff syntax) and not "fs" or "atomicwrite" (which would undersell the rollback-journal half of the mechanism).

The ordering guarantee

Apply performs, in this exact order, for the ENTIRE batch before any filesystem write happens:

  1. Validate every FileChange.Path stays within targetDir. Reject the whole batch on any violation (absolute path, "..", or a path that resolves outside targetDir via a symlink) before touching anything.
  2. If any change has Delete: true and no approval.Broker was supplied at all, reject the whole batch — see "Deletion requires approval" below.
  3. If a Broker/Scope was supplied (regardless of whether the batch contains a delete), check it now via approval.Broker.Check and reject the whole batch (no filesystem access at all) if it does not return provenance.StatusPass.
  4. Read every change's CURRENT on-disk content (or note it doesn't exist). This both populates the rollback journal's "original state" and enforces FileChange.ExpectedPriorContent: if the file was edited by someone else since the caller computed this change set against a known baseline, that drift is caught here and the whole batch is rejected before any write — this is the concrete mechanism behind "preserve dirty-worktree state and unrelated edits."

Only after all four steps pass for the entire batch does Apply write anything, one change at a time, via a temp-file-then-os.Rename pattern (same directory as the target file, so the rename is same-filesystem and atomic per file). If any individual write or delete fails partway through, Apply immediately rolls back every change already applied earlier in the same call, using the journal entries captured so far, before returning the error.

This ordering is deliberate and matters: validating paths before consulting the approval broker means an unapproved or malformed batch never spends a single-use grant; checking approval before reading content means an unapproved destructive batch never even touches the filesystem to look; and checking for drift before writing anything means a batch that would have clobbered a human's unrelated edit never lands a single byte. Apply returns either (a) success with everything applied and a Journal describing it, or (b) an error with everything already rolled back to targetDir's pre-Apply state — there is no third outcome where a caller observes a partially-applied batch as Apply's own return.

Deletion requires approval — the precise rule

A batch containing ANY FileChange with Delete: true is rejected outright, before touching the filesystem, unless ApplyOptions.Broker is non-nil AND approval.Broker.Check returns provenance.StatusPass for ApplyOptions.Scope. There is no way to delete a file through this package without a broker configured and an approved grant for the exact scope presented — a nil Broker is not "caller opted out of gating," it is "deletion is refused, full stop."

A pure-write batch (no Delete: true entries) may proceed with a nil Broker: overwriting a file's content is recoverable via this package's own rollback journal (the original bytes are always retained), whereas a deletion followed by a process crash before Rollback is ever called is not recoverable by anything this package controls. That asymmetry — not "writes are safe and deletes are dangerous" as a blanket claim, but specifically "a write's own undo path lives entirely inside this package's journal, while a delete's undo path depends on that journal surviving and someone calling Rollback" — is why the line is drawn at Delete rather than at "any mutation." A caller applying to a real, possibly-shared worktree should still supply a Broker for write-only batches too, and should always set FileChange.ExpectedPriorContent; the "no broker required for pure writes" path is a documented default, not a recommendation.

If a Broker/Scope is supplied for a pure-write batch, it is still checked (step 3 above) — supplying a Broker signals "gate this batch," independent of whether it happens to contain a delete. Note that approval.Broker.Check consumes a single-use grant on a match, exactly once, at step 3 — before the drift check in step 4. If the batch is later rejected in step 4 (content drift) or fails partway through its writes (step 5), the grant has already been spent; the caller must obtain a fresh grant to retry. This is a direct consequence of the required step ordering (approval must be checked before the filesystem is touched at all) combined with approval.Broker's single-use-grant design, not an oversight — see approval.Broker.DryRunCheck if a caller wants to preview approval without spending a grant before attempting a real Apply call.

Diagnosable without leakage

Any error Apply, Rollback, or Verify returns, and Journal's Journal.String method, are safe to log or display: Journal.String never includes file content at all (only paths, existed-before flags, and outcomes), and the few error paths that do include a content preview for diagnosis (an FileChange.ExpectedPriorContent mismatch, or a Verify drift report) run that preview through the same best-effort secret-pattern redaction this repository's provenance and [contract] packages use (see secrets.go — the patterns are copied locally, not imported, per this package's scope; provenance/provenance.go and contract/*.go are not modified). This is a best-effort safety net, not a guarantee: it catches common, recognizable secret shapes but can miss unrecognized formats and can also over-redact ordinary text that happens to match one of the patterns. See secrets.go's doc comment.

Not yet wired into anything

This package is a standalone mechanism: no CLI, no MCP server, and no call site in this repository invokes it yet. It operates on whatever targetDir a caller gives it; this package does not create git worktrees, branches, or any other form of isolation itself — a caller that wants "isolated worktree" semantics (per ADR-0032's "The agent edits in an isolated worktree or applies an explicit patch") is expected to set that up by whatever means before calling Apply, and pass that path in as targetDir. See docs/planning/agent-atomic-patch-guide.md for a worked example and the full list of guarantees this package makes (and does not make).

Index

Constants

This section is empty.

Variables

View Source
var ErrAbsolutePath = errors.New("patchapply: path must be relative to targetDir, not absolute")

ErrAbsolutePath is returned (wrapped) when a FileChange.Path is absolute. Every path this package accepts is relative to targetDir; an absolute path is always rejected outright, never silently reinterpreted as relative.

View Source
var ErrApprovalRequired = errors.New("patchapply: approval required")

ErrApprovalRequired is returned (wrapped, via fmt.Errorf's %w) by Apply when a batch requires approval that was not granted — either because it contains a Delete with no Broker configured at all, or because a supplied Broker/Scope did not return provenance.StatusPass. See the package doc comment's "Deletion requires approval" section.

View Source
var ErrEmptyPath = errors.New("patchapply: path must not be empty")

ErrEmptyPath is returned (wrapped) when a FileChange.Path is empty.

View Source
var ErrJournalNotRestorable = errors.New("patchapply: journal entry claims the path existed but has no original content to restore (was this Journal serialized and reloaded? see the package doc comment)")

ErrJournalNotRestorable is returned (wrapped) by Rollback when a JournalEntry claims a path existed before Apply touched it (ExistedBefore is true) but carries no OriginalContent to restore. See the package doc comment's "Journals are in-memory only" section: because JournalEntry.OriginalContent is deliberately excluded from JSON (it may contain secret-shaped file content that must never enter a persisted diagnostic artifact), marshaling a Journal to JSON and unmarshaling it back silently collapses OriginalContent to nil for every entry — Rollback would otherwise overwrite an existing file with empty content instead of its real prior bytes, a silent data-loss bug rather than a caught one. A genuine capture via Apply always sets OriginalContent to a non-nil slice for an existing file (os.ReadFile returns a non-nil, zero-length slice for an empty file, never nil — only readCurrent's not-exists path returns nil, paired with ExistedBefore false), so ExistedBefore=true with a nil OriginalContent can only mean the Journal passed to Rollback was reconstructed from something other than Apply's own return value — most likely a JSON round-trip. Rollback refuses to guess in that case.

View Source
var ErrPathTraversal = errors.New("patchapply: path escapes targetDir")

ErrPathTraversal is returned (wrapped) when a FileChange.Path would escape targetDir — via a literal ".." component or via a symlink planted somewhere in the existing portion of the path. This is the same class of bug as a shell-injection vulnerability, for the filesystem instead of a shell, and is treated with the same seriousness: rejected outright, never clamped or sanitized into something "safe."

View Source
var ErrPriorContentMismatch = errors.New("patchapply: prior content mismatch")

ErrPriorContentMismatch is returned (wrapped) by Apply when a FileChange.ExpectedPriorContent does not match the file's current on-disk content — the mechanism behind "preserve dirty-worktree state and unrelated edits."

Functions

func Rollback

func Rollback(targetDir string, j Journal) error

Rollback restores targetDir to the state recorded in j — the state immediately before the Apply call that produced j. It is exposed as a standalone function (distinct from Apply's own internal partial-failure rollback) for a caller who wants to undo a previously SUCCESSFUL Apply call later, e.g. after review or a failed downstream step. j must be the Journal Apply actually returned (or a value derived from it within the same process) — see Journal's doc comment for why serializing and reloading a Journal is unsafe, and the ErrJournalNotRestorable error Rollback returns instead of guessing when it detects that has happened.

Rollback is idempotent-ish in the sense that restoring a path that already matches j's recorded original state is a harmless no-op write or no-op remove; it does not, however, re-check FileChange.ExpectedPriorContent or otherwise guard against a third party having modified a file again after Apply and before Rollback — Rollback unconditionally overwrites/removes to match j. Use Verify afterward to confirm the result.

func Verify

func Verify(targetDir string, j Journal) error

Verify re-reads every file j references and confirms targetDir's on-disk state exactly matches j's recorded pre-Apply state (Path, ExistedBefore, OriginalContent for every entry). It is intended to be called after Rollback to confirm restoration succeeded, satisfying the "rollback and repeat-verification behavior are tested" acceptance criterion — but it works equally well as a drift check at any other time, since it only ever reads.

A non-nil error names every file that does not match, joined via errors.Join; nil means targetDir exactly matches j's recorded state.

Types

type ApplyOptions

type ApplyOptions struct {
	// Broker, if non-nil, is consulted via [approval.Broker.Check] before
	// any filesystem access. Required (non-nil, with an approved Scope)
	// for any batch containing a Delete: true change; optional otherwise.
	Broker *approval.Broker
	// Scope is the exact action/resource pair checked against Broker. Only
	// consulted if Broker is non-nil.
	Scope approval.Scope
}

ApplyOptions configures Apply's approval gating. See the package doc comment's "Deletion requires approval" section for the exact rule this enforces, and approval.Broker's own doc comment for what "approved" means (a matching, unexpired, unused approval.Grant).

type EntryOutcome

type EntryOutcome string

EntryOutcome records what actually happened to one JournalEntry during Apply.

const (
	// OutcomeWritten means new content was written to the path (the path
	// may or may not have existed before).
	OutcomeWritten EntryOutcome = "written"
	// OutcomeDeleted means the path existed and was removed.
	OutcomeDeleted EntryOutcome = "deleted"
	// OutcomeNoop means a Delete change targeted a path that already did
	// not exist: nothing was removed, since there was nothing there.
	OutcomeNoop EntryOutcome = "noop"
)

type FileChange

type FileChange struct {
	// Path is relative to the target directory passed to [Apply]. It is
	// validated per the package doc comment's ordering guarantee step 1:
	// an absolute path, a path containing "..", or a path whose nearest
	// existing ancestor directory resolves outside the target directory
	// via a symlink is rejected — the whole batch, not just this entry.
	Path string
	// NewContent is the file's new content. Ignored if Delete is true.
	NewContent []byte
	// Delete, if true, means Path should be removed rather than written.
	// See the package doc comment's "Deletion requires approval" section:
	// a batch containing any Delete: true entry requires an approved
	// [ApplyOptions.Broker]/[ApplyOptions.Scope].
	Delete bool
	// ExpectedPriorContent, if non-nil, must exactly match (via
	// [bytes.Equal]) the file's CURRENT on-disk content before Apply
	// writes anything, checked across the entire batch before any file in
	// the batch is touched. This is the mechanism for "preserves unrelated
	// dirty-worktree changes": if a human edited this file since the
	// caller computed this change set against a known baseline, the
	// mismatch is caught here and the ENTIRE Apply call fails before any
	// write happens, rather than silently overwriting their edit.
	//
	// nil means "don't check, apply unconditionally" — document to your
	// own callers that this is less safe, and that a caller applying to a
	// real, possibly-dirty worktree should always set this to the exact
	// content the change set was computed against (or to nil only when
	// the caller has some other, equally strong guarantee that the file
	// has not changed).
	//
	// Because comparison uses [bytes.Equal], a nil current file (does not
	// exist) and an ExpectedPriorContent of a non-nil empty slice
	// ([]byte{}) compare equal, identically to an existing empty file —
	// this package does not distinguish "expected absent" from "expected
	// present but empty." A caller that must distinguish those two cases
	// needs to track existence itself, separately from this field.
	ExpectedPriorContent []byte
}

FileChange describes one intended mutation to a single file within a target directory: either write NewContent to Path, or delete Path. This is a content-based description of a mutation, not a unified-diff hunk — see the package doc comment's "Why patchapply and not diff-format parsing" section.

type Journal

type Journal struct {
	TargetDir string         `json:"target_dir"`
	Entries   []JournalEntry `json:"entries"`
}

Journal records, per file, enough information to exactly restore prior state (Rollback) or confirm restoration (Verify) after an Apply call. TargetDir is the resolved (symlink-free) absolute directory the journal applies to; Rollback and Verify reject a Journal produced for a different directory.

A Journal is an in-memory handle, not a durable or portable artifact — see JournalEntry's doc comment for why serializing one (to JSON or any other format) and reloading it loses the information Rollback needs to restore an existing file's content, and how Rollback responds to that (a loud ErrJournalNotRestorable error, not silent data loss).

func Apply

func Apply(targetDir string, changes []FileChange, opts ApplyOptions) (Journal, error)

Apply validates and applies changes to files within targetDir as a single all-or-nothing batch, recording a rollback Journal as it goes. See the package doc comment's "The ordering guarantee" section for the exact algorithm and why the order matters.

Apply returns either:

  • success: every change was applied, and the returned Journal describes exactly what happened to every file (safe to pass to Rollback or Verify later); or
  • failure: the returned error explains what went wrong, targetDir has been restored to its exact pre-Apply state (any change already applied earlier in this same call was rolled back), and the returned Journal is the zero value.

There is no third outcome: a caller never observes a partially-applied batch as Apply's own return value.

func (Journal) String

func (j Journal) String() string

String renders j as a redaction-free-by-construction summary: path, existed-before flag, and outcome for every entry, and NOTHING from OriginalContent or any change's NewContent. This is always safe to log, print, or embed in a provenance/handoff artifact — there is no file content anywhere in this representation to leak in the first place. See the package doc comment's "Diagnosable without leakage" section.

type JournalEntry

type JournalEntry struct {
	// Path is relative to the Journal's TargetDir, exactly as given in the
	// originating FileChange.
	Path string `json:"path"`
	// ExistedBefore records whether Path existed on disk before Apply
	// touched it.
	ExistedBefore bool `json:"existed_before"`
	// OriginalContent is the file's exact content before Apply touched
	// it, or nil if it did not exist. This is what Rollback restores and
	// Verify compares against — never redact or truncate this field, only
	// its rendering in error messages and Journal.String. Excluded from
	// JSON; see the type doc comment's "OriginalContent is deliberately
	// excluded from JSON" section before ever serializing a Journal.
	OriginalContent []byte `json:"-"`
	// Outcome records what Apply actually did to this path.
	Outcome EntryOutcome `json:"outcome"`
	// CreatedDir is the absolute path of a directory chain this entry's
	// write created (via os.MkdirAll), or "" if the parent directory
	// already existed. Rollback uses this to remove a directory chain
	// this package itself created, rather than leaving stray empty
	// directories behind after undoing a write to a brand-new path.
	CreatedDir string `json:"created_dir,omitempty"`
}

JournalEntry records everything needed to exactly restore one file's prior state, or to explain what happened to it, in a machine- and human-readable shape. OriginalContent holds the file's exact prior bytes (nil if it did not exist before) — see the package doc comment's "Diagnosable without leakage" section for why this raw content is never included in Journal.String or in any default formatting, only in Rollback/Verify's own restore/compare logic.

# OriginalContent is deliberately excluded from JSON — Journal is in-memory-only

OriginalContent is tagged json:"-" so that marshaling a Journal (e.g. to embed a summary in a log line or a provenance artifact) can never leak a file's raw — possibly secret-containing — prior content. This makes a JSON round-trip (marshal, then unmarshal back into a Journal) lossy by design: every entry's OriginalContent comes back nil, indistinguishable from "this path did not exist before Apply." Rollback refuses (with ErrJournalNotRestorable) to treat a nil OriginalContent as "restore to empty" for any entry whose ExistedBefore is true, specifically to turn that lossy round-trip into a loud, immediate error instead of silently overwriting an existing file with nothing. Treat a Journal as a same-process, in-memory handle to hand directly to Rollback or Verify later in the same program — not as a durable or portable artifact to serialize, persist to disk, or send to another process.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL