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-pathnames a checkout root with no grafts, no submodule entries and no clean or process filter that a tracked path selects: the same input checks asplan.- The checkout is clean: no modified, staged or untracked files. The one exception is
the Exit-0 receipt
praetorctl gate runleaves untracked at the checkout root,.standards-receipt.json, which.gitignoredeliberately 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 (withoutGateReceiptininternal/operationalsync/prepare.go, replayed ininternal/operationalsync/receipt_tolerance_test.go). .standards.yamlat HEAD still carries the public source identity:repository.visibilityispublicandrepository.ownerdiffers from--owner.- The
originremote is<owner>/<name>and theupstreamremote is the manifest's<source owner>/<name>. --owneris 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).
planreads 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.mdis compared without its governance block body. A hand edit, or a rendering of another document, is refused asunexpected owner override in <path>.preparerenders every surface of--source-shafrom 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. Anup-to-datecandidate 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_pathslists 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.nameon 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
praetorctlrenders for that state. A hand-edited block, or one another engine version wrote, stops the operation before any candidate exists withREADME.md governance block of the reviewed source: managed README governance block is stale; runpreparewith thepraetorctlbuilt at--source-sha. - A source without
README.mdgets none, and aREADME.mdwithout the block gets no block inserted: likeadopt, the overlay never invents a README. A block with neither the documentation contract nor anAGENTS.mdlink names no repository and stays unchanged (TestReadmeGovernanceOverlayInventsNothingininternal/operationalsync/readme_test.go); one with either links the fork'sAGENTS.mdafter the overlay (TestReadmeGovernanceOverlayRebindsTheAgentsLink). plancompares the owner'sREADME.mdwith 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 asunexpected owner override in README.md.changed_pathslistsREADME.mdwhen the rendered block differs from the source's. Anup-to-datecandidate 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-shaand 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:
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.