Back to Gitnexus

Evidence provenance serializer v2 and safe plan writer

gitnexus-claude-plugin/skills/gitnexus-plan/references/evidence-provenance.md

1.6.1017.2 KB
Original Source

Evidence provenance serializer v2 and safe plan writer

This file is the normative byte contract for evidence_provenance schema 2. The adjacent scripts/evidence-provenance.mjs is its executable definition. gitnexus-plan and gitnexus-work carry byte-identical copies so either skill can produce the same snapshot without relying on the other skill's install. It is also the only supported write boundary for a generated plan. Never recreate the digest with an ad-hoc shell pipeline or write the plan destination directly.

Invocation

From the target repository root, run the helper belonging to the active skill:

bash
node <skill-dir>/scripts/evidence-provenance.mjs read-plan \
  --repo "$PWD" \
  --generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md

read-plan is the only supported way to load an existing plan for Deepen or execution. It emits a JSON receipt with the canonical generated_plan_path, bytes_read, exact plan_bytes_base64, and plan_digest (sha256:<hex>). Decode and consume those exact bytes; do not reopen the lexical path. Retain the canonical path and digest together for the complete Deepen session; a receipt for one path never authorizes another, even when their bytes match.

bash
node <skill-dir>/scripts/evidence-provenance.mjs snapshot \
  --repo "$PWD" \
  --schema-version 2 \
  --generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
  --cited src/one.ts \
  --cited test/one.test.ts

Pass one --cited argument for every cited path. The helper emits the complete JSON value for evidence_provenance; copy that value without rewriting fields. gitnexus-work passes the plan's schema_version, generated_plan_path, and every path in cited_path_manifest. Schema 1 is legacy and deliberately rejected, so the executor must conservatively re-anchor it under schema 2.

After the snapshot is in the fully composed document, publish its exact UTF-8 bytes through the same helper:

bash
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
  --repo "$PWD" \
  --generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
  < /path/to/outside-repo-scratch-plan.md

For Deepen only:

bash
node <skill-dir>/scripts/evidence-provenance.mjs write-plan \
  --repo "$PWD" \
  --generated-plan docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
  --replace \
  --expected-plan-path docs/plans/YYYY-MM-DD-gitnexus-plan-example-change-plan.md \
  --expected-plan-digest 'sha256:<digest-from-read-plan>' \
  < /path/to/outside-repo-scratch-plan.md

Initial planning never passes --replace; an existing destination is an error. Deepen mode rewrites the same path by adding --replace, --expected-plan-path <generated_plan_path-from-read-plan>, and --expected-plan-digest <plan_digest-from-that-same-receipt>. Standard input must be valid UTF-8 and at most 16 MiB. A successful write prints a JSON receipt with the normalized generated_plan_path and bytes_written. A successful Deepen write also returns prior_plan_backup_git_path, a durable Git-admin path for the displaced plan. The CLI rejects every option that does not apply to its selected command; the direct API likewise requires literal booleans and exact digest strings rather than truthy coercion.

Path contract

Every Git path and CLI path must be valid UTF-8, already normalized to Unicode NFC, and a nonempty POSIX repo-relative path. NUL, backslash, absolute/drive paths, empty components, and . or .. components are rejected. The helper does not silently repair or alias them. Invalid UTF-8 from Git, non-NFC names, unmerged index stages, unsupported Git modes, sockets/devices/FIFOs, unreadable objects, symlink traversal in a parent path component, or a repository mutation observed during the snapshot fail closed.

The generated-plan path is always repo-relative under schema 2. Snapshot exclusion and writing require exactly docs/plans/YYYY-MM-DD-gitnexus-plan-<3-5-word-kebab-slug>.md, including a valid calendar date; they cannot target .git, source, configuration, or an arbitrary repo file. For compatibility with documented and legacy plans, read-plan accepts normalized files matching docs/plans/*gitnexus-plan*.md, while retaining the same descriptor-anchored containment checks. That read compatibility does not widen the writer. External output has no schema-2 representation. The snapshot exclusion is one exact normalized path comparison. No glob, directory, basename, or docs/plans/-wide exclusion is permitted. If the exact path is a rename endpoint, only that endpoint record is excluded.

Safe existing-plan read contract

read-plan fails closed unless the host platform can resolve names against a held directory descriptor: Linux /proc/self/fd with O_DIRECTORY and O_NOFOLLOW, or macOS O_DIRECTORY/O_NOFOLLOW. Every other platform is refused outright — an unverified read is not a degraded read, it is a different, racy operation. It resolves the exact Git top-level, opens the repository root and every plan parent as held no-follow directory descriptors, rejects missing, symlink, non-directory, and escaping parents, and opens the leaf with O_NOFOLLOW. It reads at most 16 MiB from that held file descriptor, requires valid UTF-8, hashes the exact bytes, then proves both the parent chain and lexical leaf still name the same held objects before returning its receipt. Neither Deepen nor work may parse bytes obtained before or outside this receipt.

Safe generated-plan write contract

The writer fails closed unless the host platform offers O_DIRECTORY and O_NOFOLLOW, plus /proc/self/fd on Linux. It spawns no interpreter and loads no native code: publication is link(2), which is atomic, fails EEXIST when the destination name is taken, and refuses a symlinked destination without following it — the same no-replace guarantee renameat2(RENAME_NOREPLACE) and renameatx_np(RENAME_EXCL) provide, available through fs.linkSync on every supported platform. The temporary name is unlinked once the link succeeds; the published file is the same inode the writer created and verified, so every identity check downstream holds by construction. A link that succeeds followed by an unlink that fails leaves the plan published and is reported as success, because it is one. The plan parent and the repository's Git-admin directory must also share a filesystem. It resolves the target repository's exact Git top-level, opens that root and every destination parent as held no-follow directory descriptors, creates missing parents relative to those descriptors, and proves the descriptor and lexical chains still identify the same directories at the write boundary. A symlink or non-directory parent, an escaping resolved path, a symlink/non-regular final target, or a parent swap is an error.

The writer creates a random exclusive temporary file relative to the held final parent descriptor and keeps its no-follow descriptor open. It writes and flushes the bytes, binds the temporary name to the opened inode, and hashes the open file before publication. Immediately before publication it revalidates the parent and the temporary path, inode, size, and digest. Publication links the temporary name to the destination relative to the held directory descriptor, which fails rather than replaces if the destination is taken. Initial mode therefore cannot overwrite a destination that appears after the absent check. The writer then flushes the directory and revalidates the committed path by opening it with O_NOFOLLOW, hashing both the original temporary fd and the path-bound fd, and performing a second descriptor-anchored path identity check after hashing. A detected mutation or replacement aborts instead of accepting mixed-era output.

Linux anchors, macOS verifies

The two platforms reach the same destination by different proofs, and the difference is real enough to state rather than smooth over.

On Linux every name resolves through /proc/self/fd/<fd>/<child>, a magic link the kernel resolves against the inode the descriptor already holds. The names above it are never re-walked, so an attacker who renames a parent between the check and the use cannot redirect the operation. The race is impossible, not merely detected.

macOS has no such path. /dev/fd/<fd> is a devfs node, not a magic link: it can be opened, but nothing can be resolved through it. open("/dev/fd/<fd>/child") returns ENOENT, and realpath of it returns /dev/fd/<fd> rather than the directory's path — measured on macOS 26, not inferred. Node exposes no openat, no dir_fd parameter, and no FFI, so on macOS the writer resolves names lexically with O_NOFOLLOW at every component, holds an open descriptor on every directory in the chain for the whole operation, and proves before and after each step that the chain still names exactly the inodes it is holding. Holding the descriptors is what makes the recorded inode numbers trustworthy: an open descriptor pins its inode, so a freed number cannot be recycled beneath the walk.

What that buys is detection rather than prevention. A parent swapped inside the window between a check and its use is caught by the check that follows, and the operation aborts having written nothing — but on Linux it could not have happened at all. No published byte escapes verification on either platform.

--replace accepts only a pre-existing regular file and is reserved for Deepen; without it, accidental overwrite is rejected. It also requires the exact canonical generated_plan_path and plan_digest from the same session's read-plan receipt. The expected path must exactly equal the write destination, so identical bytes from one plan cannot authorize another plan. Immediately before preservation, the writer hashes the still-held prior-plan fd and rejects any digest, inode, or path mismatch, including same-inode edits and changes between read and write. It then atomically moves the current destination without replacement to a random gitnexus-plan-backups/ file under the resolved Git-admin directory and verifies the moved inode and digest against that held fd. Only then does it publish the new plan with the same atomic no-replace primitive. A destination that reappears at either boundary is left untouched.

Every newly created plan or vault directory is fsynced and then fsynced into its containing directory. Every cross-directory preservation move fsyncs both its source and destination directories before success or a recovery path is reported. After temporary bytes exist, a failed publication or verification preserves every available prior, displaced, unpublished, or intended plan in that Git-admin vault before reporting failure. Each reported recovery is reopened from a freshly resolved Git root and verified before the error names it as git-path:gitnexus-plan-backups/<random-name>. Resolve that value with git rev-parse --git-path gitnexus-plan-backups/<random-name>; never interpret it as a repo-relative working-tree path. This remains valid if the held plan parent was renamed after publication. The writer never reports recovery through a stale lexical parent and never performs an identity-check-then-unlink rollback that could delete a racer's replacement. Read-only or unsupported checkouts produce a blocking error. Callers must not bypass the helper, redirect to an external path, or weaken these checks.

Canonical bytes

The global_dirty_digest.value is lowercase SHA-256 (without a sha256: prefix) over this byte stream. All textual values are their exact UTF-8 bytes. NUL below is one 0x00 byte.

  1. Prefix fields, each followed by NUL, then one additional NUL: gitnexus-evidence-provenance, schema_version, 2.
  2. Zero or more records sorted by unsigned lexicographic comparison of the normalized path's UTF-8 bytes. Locale and filesystem order are forbidden.
  3. Each record is record + NUL, then the following fixed-order sequence of field-name + NUL + field-value + NUL pairs, then one additional NUL: path, state, head_kind, index_kind, worktree_kind, untracked_kind, rename_from, rename_to, head_digest, index_digest, worktree_digest, untracked_digest.
  4. The literal absent represents every unavailable rename endpoint, object kind, and layer digest in canonical bytes. It is never an empty string.

The schema's canonicalization literal is exactly gitnexus-evidence-provenance-v2 NUL-framed UTF-8 records. The fixed field count plus the extra NUL after prefix/record makes framing unambiguous; values cannot contain NUL. Duplicate normalized paths are rejected.

Records, renames, and states

The raw dirty set comes from Git porcelain v2 with NUL termination, all untracked files, submodule inspection enabled, a fixed 50% rename threshold, and both diff.renameLimit=0 and status.renameLimit=0, so repository config cannot cap rename candidates. Raw porcelain facts that share a path are merged into one canonical record. A rename contributes two endpoint facts:

  • old endpoint: path=<old>, rename_from=absent, rename_to=<new>;
  • new endpoint: path=<new>, rename_from=<old>, rename_to=absent.

Both normally have state renamed; record sorting, not old/new role, determines order. A worktree-dirty rename destination or any endpoint that also has another fact is mixed, with rename metadata retained. When either endpoint is cited, the cited manifest expands to include both.

Ordinary XY status maps to mixed when index and worktree columns are both dirty, otherwise deleted for a deletion, staged for index-only change, and unstaged for worktree-only change. ? is untracked. Multiple distinct facts for the same path become mixed; a staged deletion plus a recreated file therefore retains HEAD/index facts while the filesystem object is recorded in the untracked layer. ? child/ is Git's embedded-directory marker: the trailing slash is removed before path normalization and child is materialized as one bounded directory object. A cited path outside the dirty set is clean, untracked when it exists only outside Git layers, or absent when no layer exists.

Object and digest rules

Every present layer digest is sha256:<lowercase-hex>:

  • HEAD regular/symlink: SHA-256 of the exact Git blob bytes. HEAD directory: SHA-256 of the exact raw Git tree bytes. HEAD gitlink: SHA-256 of the ASCII object ID stored by the tree.
  • Index regular/symlink: SHA-256 of the stage-0 Git blob bytes. Index gitlink: SHA-256 of its ASCII object ID. The index has no directory layer. Any non-stage-0 entry is rejected.
  • Tracked worktree regular: raw file bytes, opened without following symlinks. Symlink: raw link-target bytes. Gitlink: ASCII object ID at the checked-out nested HEAD, but only after rev-parse --show-toplevel proves that the directory itself is the nested repository root, HEAD resolves there, and porcelain v2 reports no staged, unstaged, untracked, or ignored nested changes. The same root, HEAD, and clean-status proof is repeated by the mutation guard. A dirty, empty, uninitialized, or parent-falling-through gitlink fails closed. Directory: the v1 directory stream described below.
  • A path absent from both HEAD and index places the filesystem object in the untracked layer and marks worktree absent. A Git-backed path places it in worktree and marks untracked absent. A missing layer uses literal absent for both kind and digest; an empty file is the SHA-256 of zero bytes.

Filesystem directory bytes use prefix fields gitnexus-evidence-directory, schema_version, 1, the same NUL framing, and recursive entries sorted by unsigned UTF-8 relative-path bytes. Each entry has fixed fields path, kind, digest. A single bottom-up filesystem walk visits each node once and returns each child digest plus the flattened subtree needed to preserve those canonical bytes; links are never followed. When the directory is proven to be an exact nested Git top-level, only its administrative .git entry is excluded. Every other child, including working files and nested directories, remains evidence.

Each directory object is bounded to 10,000 visited entries, depth 256, and 256 MiB of regular-file content. Exceeding a bound fails closed. These bounds apply independently to each top-level directory object materialized by a record.

HEAD objects are read only from the full object ID captured at snapshot start; the symbolic HEAD name is never re-resolved for layers. Index layers are parsed from one captured stage-0 listing. The helper guards the corresponding HEAD/ref/reflog controls and raw index file, compares the captured listing at the end, and rejects ordinary A-to-B-to-A mutations instead of accepting mixed-era layers.

Regular files are read through an O_NOFOLLOW descriptor with before/after identity checks. Symlinks use lstat/readlink/lstat; directories record identity before and after their inventory. The helper also compares raw porcelain-v2 status and HEAD at the start and end, then rechecks filesystem guards. An absent cited path holds a no-follow descriptor for the nearest existing parent and records the first missing component or leaf; that anchored absence is checked both before and after the final Git status pass, so a newly created ignored path cannot evade porcelain. Any observed race rejects the snapshot rather than emitting mixed-era evidence.