Skip to content

Operational fork synchronization

praetorctl operational sync supports three local stages: init, plan and prepare. It consumes existing Git remotes, reviewed commits, and .standards.yaml version 1. It does not add a rollout configuration, install a bot, or contact GitHub. This initial workstation implementation is verified on Linux with Git 2.55.0.

The initial contract supports a private operational repository with the same name as its public source and a different owner. The owner's origin must match its manifest; its upstream must match the public manifest and source checkout's origin. Credential-free GitHub HTTPS and git@github.com: remotes are accepted. These are local identity checks, not verification of live GitHub privacy settings.

Apply the first overlay

A new operational fork starts as a checkout of the public source. init writes the owner overlay into that checkout, so the first fork commit is engine output that plan later accepts, not a hand edit it has to match byte for byte.

praetorctl operational sync init \
  --owner-path /path/to/private/praetor \
  --owner "$operational_owner"

init requires all of the following and writes nothing until they hold:

  • --owner-path names a checkout root with no grafts, no submodule entries and no clean or process filter that a tracked path selects: the same input checks as plan.
  • The checkout is clean: no modified, staged or untracked files. The one exception is the Exit-0 receipt praetorctl gate run leaves untracked at the checkout root, .standards-receipt.json, which .gitignore deliberately keeps visible. It is tolerated only as a regular file at that exact path: a link or a directory there, the same name in a subdirectory, or a staged receipt is still a change (withoutGateReceipt in internal/operationalsync/prepare.go, replayed in internal/operationalsync/receipt_tolerance_test.go).
  • .standards.yaml at HEAD still carries the public source identity: repository.visibility is public and repository.owner differs from --owner.
  • The origin remote is <owner>/<name> and the upstream remote is the manifest's <source owner>/<name>.
  • --owner is a valid GitHub owner name.

init accepts no other flag: --source-path, the three SHA flags and --destination are refused, and plan and prepare refuse --owner, which they read from the reviewed manifest instead.

init writes the four overlay files into the working tree with the same transformation plan and prepare verify against, with owner visibility private. It stages nothing, commits nothing, creates no clone and contacts nothing. After writing it verifies that HEAD did not move and that the working tree differs from HEAD in exactly the four overlay paths, unstaged. Otherwise the report status is failed with the error and the files stay in place for inspection; git checkout -- <the four paths> restores them.

The report records stage init and status initialized. options.owner_sha records the HEAD the overlay was derived from, changed_paths lists the four files, and owner_only_paths is [].

Review the diff and commit it signed and signed-off, for example as chore(operational): apply owner overlay. plan then accepts that commit: with --base-sha and --source-sha both set to the public tip the fork was cloned from it reports planned, because a commit is its own ancestor. A second init is refused: before the commit because the checkout is dirty, after it because the manifest no longer carries the public identity.

Plan from reviewed commits

Fetch and review source changes separately. Supply full lowercase 40-character commit SHAs already present in the local repositories. The owner checkout must be clean, the gate's untracked receipt excepted as for init, and its HEAD must equal --owner-sha. The previous public commit must be an ancestor of both the owner and the new source. Missing objects, unrelated history, and a changed reviewed owner HEAD are errors.

Both input paths must name checkout roots. Neither may configure a clean or process filter that the filter attribute of a tracked path selects, whether the driver sits in the repository's own configuration, an included file or worktree configuration, because status on the owner checkout would run it. The check is util.RefuseGitStatusFilters in internal/util/git_filters.go, the one every clean-tree probe uses, so a driver no tracked path selects, such as the filter.lfs block git lfs install --local writes, passes here as it does for state sync (#667). A smudge-only driver passes too: smudge runs on checkout, no input is ever checked out, and prepare checks out and merges in a fresh clone that carries none of the inputs' configuration. The TestRunInputFilters_* cases in internal/operationalsync/input_safety_test.go replay each of these.

Input submodule worktrees and local info/grafts metadata are rejected before status inspection. Replacement objects and automatic fetching of missing objects are disabled so local metadata cannot substitute reviewed commit ancestry. See Git's replacement and lazy-fetch controls.

praetorctl operational sync plan \
  --owner-path /path/to/private/praetor \
  --source-path /path/to/public/praetor \
  --owner-sha "$reviewed_owner_sha" \
  --base-sha "$incorporated_public_sha" \
  --source-sha "$reviewed_public_sha"

The existing owner tree must equal the incorporated source except for the owner-only operator paths, the funding surfaces, the README governance block, and these four files and these specific identity fields:

File Allowed owner difference
.standards.yaml repository.owner, repository.visibility, and repository.source
.devcontainer/devcontainer.json root name
.paperclip/harness.json root platform
.paperclip/rules.md repository identity in the generated first heading

repository.source records the public identity the overlay is rewriting repository.owner/repository.name away from, as <owner>/<name> -- the fork's own manifest describes its own owner while still naming the source it was cloned from. The overlay writes it on the first init and keeps it current on every later plan/prepare. The canonical repository's own manifest never carries it. An owner manifest overlaid before the field existed (any fork that ran init before this field shipped) has neither: plan still accepts it -- a missing field the overlay itself adds is not an unexpected owner override -- and prepare writes it into the candidate, the same way a real merge already carries any overlay field through unconditionally. Only a manifest that carries a repository.source differing from the actual public source is refused, since that is a wrong recorded identity rather than a manifest predating the field. See Which workflows run where for why the fork needs it at all.

Unexpected engine differences, extra policy overrides, derived configuration drift, or missing/symlinked configuration files stop the operation. Unknown upstream YAML fields and comments are retained through yaml.Node; the owner manifest is never round-tripped through the narrower Go config.Manifest struct. Identity anchors are rejected, and so are identical duplicate JSON keys at any depth and a second spelling of the replaced top-level key, such as name beside Name (internal/strictjson, derivedJSON in internal/operationalsync/json.go, ownerJSON in overlay.go). Nested keys that differ only in case, such as HTTP_PROXY and http_proxy build arguments, are data and are kept. Derived JSON must retain the existing generator's scalar formatting; ambiguous replacements are rejected.

Funding surfaces

The engine commits the unconfigured rendering of its funding surfaces (funding.SurfacePaths in internal/funding/render.go): .github/FUNDING.yml and the marked blocks of README.md, mkdocs.yml, docs/sponsoring.md and docs/monetization.md. The fork carries the operator's funding document at .config/operator/funding.yaml, an owner-only path, and the overlay renders those surfaces from it with the renderer behind praetorctl docs funding (funding example).

  • plan reads the document from --owner-sha. An invalid document is an error. Each owner funding surface must equal either the incorporated source's file, not rendered yet, or that file rendered from the document; README.md is compared without its governance block body. A hand edit, or a rendering of another document, is refused as unexpected owner override in <path>.
  • prepare renders every surface of --source-sha from the document, writes and stages the rendering in the candidate, and resolves a merge conflict in a surface the same way it resolves one in an identity overlay file. An up-to-date candidate whose owner tree still carries the unrendered surfaces receives the rendering, staged.
  • The candidate must then carry exactly the rendering: each surface present with the rendered bytes, or absent when the rendering has no such file.
  • changed_paths lists the surfaces whose rendering differs from the reviewed source's file after the four identity overlay files. Without a document nothing differs and none is listed.

init does not render the surfaces: a fork gets its document after init, and the next prepare renders it. internal/operationalsync/funding_test.go replays the rendered merge, a conflict inside a rendered block, the refused edits and the up-to-date cases.

README governance block

README.md also carries the managed governance block that praetorctl adopt writes and praetorctl audit verifies (internal/readmegovernance). Its managed HISS badge links AGENTS.md when the block records a known forge, and with the documentation contract its workflow badge links the workflow runs, both by absolute URL into the repository the manifest names (README governance): the engine's block names the public source, and the fork's own audit expects the fork. plan and prepare render the block for the owner with that same renderer:

  • They read the recorded state back from the reviewed source's block (debt baseline, documentation contract and forge host) and render it again for the owner's repository.owner/repository.name on the same forge. Every byte outside the block, funding blocks included, stays as the source has it.
  • The source block must be exactly what the running praetorctl renders for that state. A hand-edited block, or one another engine version wrote, stops the operation before any candidate exists with README.md governance block of the reviewed source: managed README governance block is stale; run prepare with the praetorctl built at --source-sha.
  • A source without README.md gets none, and a README.md without the block gets no block inserted: like adopt, the overlay never invents a README. A block with neither the documentation contract nor an AGENTS.md link names no repository and stays unchanged (TestReadmeGovernanceOverlayInventsNothing in internal/operationalsync/readme_test.go); one with either links the fork's AGENTS.md after the overlay (TestReadmeGovernanceOverlayRebindsTheAgentsLink).
  • plan compares the owner's README.md with the block's body left out, because the overlay renders that body again. An owner block naming the source (a fork synced before this overlay existed), naming the owner, or worded by an older engine is accepted; a difference outside the block is refused as unexpected owner override in README.md.
  • changed_paths lists README.md when the rendered block differs from the source's. An up-to-date candidate whose owner tree still names the source receives the rendering, staged.

init does not render the block, the same as the funding surfaces; the first prepare does. internal/operationalsync/readme_test.go replays the rendered merge and the next sync, the preserved outside text, the refused cases and the up-to-date cases.

Owner-only operator paths

An operational fork also carries operator files the public source never has: fleet topology, runner routing, cluster manifests, workstation settings. plan accepts them in the owner tree and prepare in the candidate tree, under a fixed list of owner-only prefixes:

Prefix Kind Matches Does not match
.config/fleet.yaml exact file .config/fleet.yaml .config/fleet.yaml.bak
.config/fleet-topology.yaml exact file .config/fleet-topology.yaml
.config/orgs/ directory .config/orgs/example.yaml, .config/orgs/deep/a.yaml .config/orgsx/a.yaml, .config/orgs.yaml
.config/operator/ directory .config/operator/fleet.yaml
deploy/arc/ directory deploy/arc/scale-set.yaml
deploy/k8s/ directory deploy/k8s/app/kustomization.yaml deploy/k8sx/a.yaml, deploy/helm/values.yaml

Matching is on whole path segments. A path under a prefix is accepted only when all of these hold:

  • the public source has no such path at --base-sha and none at --source-sha, compared without regard to letter case, because two names that differ only by case are one file in a case-insensitive checkout;
  • the entry is a regular non-executable file, Git mode 100644: symlinks, submodule links and executable files are refused;
  • it is at most 1 MiB;
  • at most 256 such paths exist in total.

Exceeding a bound is an error. prepare additionally requires the candidate to carry exactly the owner-only paths the reviewed owner tree carried. The report lists them as owner_only_paths, a sorted array that is [] when there are none.

The list is engine schema, not operator data, and is not configurable. The engine's own .gitignore ignores every prefix, and an engine test replays git check-ignore for each one, so a prefix cannot be added that the public source could also track. Because the paths are ignored, the fork tracks them with git add -f.

Everything else outside the four overlay files and the funding surfaces is still refused with unexpected owner tree difference; a fork-only GitHub workflow file is therefore not a legal owner path. The tree comparison runs with rename detection disabled, so moving an engine file under an owner-only prefix is reported as the deletion of that engine file and refused. A merge conflict in an owner-only path is not resolved by the engine; only the four overlay files and the funding surfaces are.

Prepare an ordinary merge

Use the same arguments with prepare and a new destination outside both input repositories. The destination's parent must exist and have no symlink ancestors.

praetorctl operational sync prepare \
  --owner-path /path/to/private/praetor \
  --source-path /path/to/public/praetor \
  --owner-sha "$reviewed_owner_sha" \
  --base-sha "$incorporated_public_sha" \
  --source-sha "$reviewed_public_sha" \
  --destination /path/to/worktrees/reviewed-operational-candidate

Preparation creates a private clone and an operational-sync-candidate branch. It runs an ordinary git merge --no-ff --no-commit, resolves only the four owner configuration paths and the rendered funding surfaces, and stages those exact paths. The candidate HEAD remains the reviewed owner commit; MERGE_HEAD identifies the reviewed new source. A later normal merge commit therefore retains published owner ancestry. An already incorporated source produces a clean up-to-date candidate.

The final index tree must equal the reviewed source outside the owner overlay, the rendered funding surfaces, the rendered README governance block and the owner-only operator paths. Unresolved conflicts, unstaged modifications and untracked files are errors. No source code, build scripts, hooks, filters, submodules or GitHub workflows are executed by preparation. New clones use inert Git templates and an isolated Git environment; neither input repository's configuration or hooks are modified. Only the trusted running Praetor implementation transforms configuration data.

JSON output distinguishes initialized, planned, preparing, prepared, up-to-date, and failed states. Failure after clone creation retains the candidate path and error for inspection; it is not automatically deleted or overwritten on retry. Runtime is capped at two minutes, Git output at 4 MiB per stream, each configuration blob and each owner-only file at 1 MiB, and the owner-only paths at 256. Exceeding a bound is an error, never successful partial coverage.

Which workflows run where

An operational fork carries the engine's workflow files unchanged, because a fork-only workflow file would be an unexpected owner tree difference. What differs is which jobs run. Engine-only jobs carry one condition:

if: github.repository == (vars.PRAETOR_CANONICAL_REPOSITORY || 'cordanaLLM/praetor')

Repository variables are not inherited by a fork, so the literal decides there. The guard resolves the repository identity from .standards.yaml: repository.source when the manifest carries one, otherwise repository.owner/repository.name. This is why the overlay records repository.source (see Plan from reviewed commits): the overlay rewrites repository.owner to the fork's own identity but never touches the checked-in workflow files, whose guard literals keep naming the public source. Reading repository.owner/repository.name there instead would compare the fork's own identity against a literal that still names the source, read every guarded job as conditional, and desynchronize the fork's required status contexts from the checked-in ruleset -- the fork's own guard and ruleset tests failing against its own checkout (#255). A test in internal/forge parses every workflow and fails when the literal differs from the manifest identity, or when a scheduled or publishing job has no guard, and a second test replays both checks against a temp copy of the manifest with the owner overlay applied.

Workflow Canonical repository Any other copy
ci.yml, compliance.yml, security.yml on push and pull request runs runs: verification follows the code, and it gates sync candidates
security.yml schedule leg runs skipped
sync-flavors.yml, sync-models.yml, pages.yml, wiki-sync.yml, release-binaries.yml, devcontainer-refresh.yml, planning-sync.yml runs skipped at job level; the job name says canonical repository only
portability.yml runs; its three legs are required checks skipped with a stated reason unless the repository variable PRAETOR_FORK_PORTABILITY is enabled; sync --remote does not add its legs there and warns about legs an earlier sync added (HISS-21)
adopt.yml, adopt-comment.yml on dispatch and comment, respectively on dispatch and comment, respectively: a person asked for it in that repository

In every copy a pull request the Renovate app opened from a renovate/ branch also skips the Go tests, the Platform Neutrality legs, the preset builds, the security scan and the documentation site build, while the DCO and REUSE gate, the configuration validation and the documentation governance gate run (Renovate pull requests). A person's pull request from a branch named renovate/... runs every job.

The canonical repository can afford the skip because its ruleset requires the Platform Neutrality legs, which a skipped matrix never reports, and because its landing pipeline takes every Renovate pull request over. A fork without PRAETOR_FORK_PORTABILITY=enabled does not require the legs, and GitHub reports each other skipped job as successful. There a Renovate pull request shows every required check green although no Go test and no security scan ran. Do not merge a Renovate pull request directly in such a copy: take the update over on a branch of your own, or disable Renovate there and take updates from the canonical repository through the sync.

Set PRAETOR_CANONICAL_REPOSITORY only when the canonical repository itself moves; the manifest identity and the literal then change in the same commit. Guards limit what a copy runs on its own. They do not replace disabling Actions on a fork before its first push.

Review and later rollout

A prepared candidate is structural evidence, not a test result or an Exit-0 receipt. Review its diff and exact parent commits, explicitly activate the normal project hooks when it becomes a trusted working checkout, and run the applicable verification gates before committing. Publication is a separate normal fast-forward push; this command never pushes, rebases published history, merges a default branch, changes privacy/protection, opens a PR, or activates an agent.

There is currently no scheduler, GitHub App runtime, webhook consumer, automatic promotion stage, or workstation rollout consumer behind this interface. Those require separately reviewed implementations and activation. Current repository gate failures remain failures; a successful structural preparation does not waive them. The operation is currently CLI-only and has no MCP mutation tool.

Verify a receipt against a supplied key

praetorctl gate verify accepts --public-key <hex>, a hex-encoded Ed25519 public key that overrides the key pinned in .standards.yaml. This is how a fork workstation checks an Exit-0 receipt it reads from a Git note (refs/notes/praetor/receipts, section 8.3) rather than a file committed next to a manifest: the note has no .standards.yaml of its own to pin a key in, so the caller supplies the fork's own receipt.public_key directly.

git notes --ref praetor/receipts show "$target_sha" > /tmp/receipt.json
praetorctl gate verify \
  --path /path/to/checkout \
  --receipt /tmp/receipt.json \
  --public-key "$fork_receipt_public_key"

An explicit --public-key always wins over a pinned key: when .standards.yaml also carries receipt.public_key, the supplied key is still the one checked against, never a cross-check between the two. Without --public-key, gate verify is unchanged: it reads the pinned key from the manifest as before.

Verification still requires the receipt's commit_sha to equal --path's current HEAD. Supplying --public-key additionally requires the receipt's repository field to equal the identity --path resolves to (its origin remote, or an unambiguous <owner>/<repo> path shape) — a receipt minted for one repository cannot be replayed against a different checkout that merely happens to trust the same key. This check fails closed: it never falls back to guessing an identity from a bare directory name.