Skip to content

Bind the plan to the conversion and the directory, not just the files - #47

Merged
amrali-eg merged 1 commit into
masterfrom
feat/plan-semantics-binding
Aug 27, 2026
Merged

Bind the plan to the conversion and the directory, not just the files#47
amrali-eg merged 1 commit into
masterfrom
feat/plan-semantics-binding

Conversation

@amrali-eg

Copy link
Copy Markdown
Owner

Phase D.2 — the reviewer's three additions before the plan mechanism is final

#46 bound the plan to file hashes. That answers "are these the same bytes?" but not "is this the same conversion?". Three ways a valid-looking plan could still mean something other than what was approved.

1. The conversion could change underneath it

The plan now carries a semantics version next to the schema version, and -Apply refuses a plan made under different conversion behaviour rather than carrying it out.

That version is deliberately separate from the assembly version. Tying plan validity to a release number would invalidate every plan on a release that changed nothing about conversion — which teaches people to work around the check instead of reading it. It moves only when the meaning of a plan moves.

The plan also now records the target, BOM policy, backup policy, detection mode (Detected/Explicit) and the guarantees the converting build provides, so plan.json is the whole approval:

{
  "PlanVersion": 2,
  "SemanticsVersion": 1,
  "ECVersion": "3.7.0.0",
  "Semantics": {
    "StrictDecoding": true,
    "StrictEncoding": true,
    "OutputVerification": true,
    "AtomicInstall": true,
    "AmbiguityRefusal": true
  },
  "BaseDirectory": "...",
  "TargetEncoding": "utf-8",
  "TargetHasBom": false,
  "BackupEnabled": true,
  "ExplicitSourceEncoding": null,
  "DetectionMode": "Detected"
}

SemanticsVersion is what's enforced; the Semantics block is the record for the reader.

2. The tree could change underneath it

Paths were absolute. A plan copied alongside its tree still named the original tree — and every hash matched, because those were the files it was made from. The relocation hazard was real and silent.

Entries are now RelativePath against a recorded root:

  • applying a copy of a plan converts the tree it was approved for, and leaves the copy alone (test pins both halves);
  • a plan whose root no longer exists is refused cleanly instead of failing file-by-file;
  • an entry resolving outside the root — ..\..\Windows\System32 — is refused. A plan is an ordinary file people can edit and pass around.

3. The file could change between verification and write

The preflight proves every file matches when the run starts; a large tree leaves time after that. The converter's existing recheck compares length and last-write-time against what that run itself saw on opening the file, so it cannot speak to a decision made earlier.

ConversionOptions.ExpectedSourceSha256 lets a caller that committed to specific bytes insist they are still those bytes, checked at the last point before installation. -Apply sets it from the plan.

This narrows the window; it does not close it. A source rewritten between that check and File.Replace is still not caught — closing that needs every source held open against writers for the whole run, which would fail conversions of files legitimately open elsewhere. The XML doc and the README both say so rather than implying a guarantee that isn't there.

Only -Apply pays for the extra read. An ordinary conversion has nothing to compare against and keeps the cheaper check; the hash is shared with the metadata record when both are wanted, so a backed-up -Apply reads once, not twice.

Reporting invariant

Pinned as a test: the displayed categories sum exactly to the selected population, with the two indented lines breaking down the one above them and excluded from that sum.

The guarantees line now describes the running build, not the plan's own booleans — an edited plan could otherwise state something untrue about the conversion that is actually about to happen.

Selected:                     3

Will convert:                 2
  encoding determined:        2
  same text either way:       0
Already in target encoding:   0
Encoding not identified:      0
Refused, ambiguous encoding:  1
Refused, unreadable:          0

Directory:                    C:\Source
Target:                       utf-8 without BOM
Source encoding:              detected per file
Backups:                      enabled
Guarantees:                   strict codecs, verified output, atomic install, ambiguity refusal

No files modified.

Tests

11 new, 383 passing. Plan schema is version 2; plans written by the previous build are refused with a message saying to re-run -Plan, which is the intended behaviour for a schema that changed shape.

🤖 Generated with Claude Code

A plan that records only file hashes answers "are these the same bytes?" but not
"is this the same conversion?". Three ways it could still mean something other
than what was approved:

The conversion could change underneath it. The plan now records a semantics
version alongside the schema version, and -Apply refuses a plan made under
different conversion behaviour rather than carrying it out. That version is
deliberately separate from the assembly version: tying plan validity to a
release number would invalidate every plan on a release that changed nothing
about conversion, which teaches people to work around the check instead of
reading it. The plan also writes down the target, BOM policy, backup policy,
detection mode and the guarantees the converting build provides, so the file is
the whole approval and -Apply needs no ambient option state at all.

The tree could change underneath it. Paths were absolute, so a plan copied
alongside its tree still named the original tree - and every hash matched,
because those were the files it was made from. Entries are now relative to a
recorded root, applying a copy converts the tree that was approved, a missing
root is refused cleanly, and an entry resolving outside the root is refused
rather than followed. A plan is a file people can edit and pass around.

The file could change between verification and write. The preflight proves every
file matches when the run starts; a large tree leaves time after that. The
converter's existing recheck compares length and timestamp against what that run
itself saw on opening the file, so it cannot speak to a decision made earlier. A
caller that committed to specific bytes can now supply the hash it approved, and
the source is re-read and compared at the last point before installation. This
narrows the window rather than closing it - a source rewritten between that check
and File.Replace is still not caught, which would need every source held open
against writers for the whole run - and the code and README both say so. Only
-Apply pays for the extra read; an ordinary conversion has nothing to compare
against and keeps the cheaper check.

Also pins the reporting invariant: the displayed categories sum exactly to the
selected population, with the two indented lines breaking down the one above
them. The summary now describes the running build's guarantees rather than
echoing the plan's own booleans, which an edited plan could otherwise use to
claim something untrue about the conversion about to happen.

11 new tests, 383 passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amrali-eg
amrali-eg merged commit 1977c4c into master Aug 27, 2026
1 check passed
@amrali-eg
amrali-eg deleted the feat/plan-semantics-binding branch August 27, 2026 02:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant