ADR-0014: Operator-Neutral Defaults — Deployment Data Becomes Operator Configuration
Status
Accepted — 2026-09-27 (proposed 2026-09-26). This record supersedes ADR-0007 (the designation
of one framework organisation as the fleet-wide framework hub) and amends rule 4 of
docs/adr/README.md (see Decision §7). It implements stage 1 ("Shared package policy") of
docs/plans/package-development-pipeline.md.
Context
Praetor is a public governance engine. Its CLI help, MCP tool descriptions, defaults, emitted templates, generated artifacts and documentation are read by adopters, who run it against their own repositories or their own fork. The maintainer directed that every one of these surfaces address the adopter and the adopter's repositories, and that no surface present the maintainer's personal repositories, organisations, endpoints or accounts as defaults or examples.
Today several surfaces carry the maintainer's deployment instead (all at origin/main
ac3d27c2):
| Surface | Where | What an adopter gets |
|---|---|---|
| Default forge owner | cmd/standardsctl/issue.go:57, milestone.go:91, project.go:50,81,131, init.go:85, internal/adopt/adopt.go:40, internal/forge/wiki.go:20, internal/paperclip/harness.go:90 |
operations aimed at the maintainer's organisation when no owner resolves |
| Default reconcile set | cmd/standardsctl/issue.go:58 |
issue reconcile walks the maintainer's repositories |
| Target framework | internal/needs/framework.go:16-18, analyzer_{go,node,python,rust,native}.go, requests.go:66,78-90, harvester.go:186-212, migrate.go:21, catalog.go |
needs reports, migrations and demand requests routed to the maintainer's framework kits |
| Report and help text | cmd/standardsctl/main.go:168, needs.go:55,61,124, cmd/standards-mcp/server.go:677,703,711, internal/needs/aggregate.go:440,547 |
the maintainer's framework named in headers and tool descriptions |
| Serialized key | internal/needs/types.go:58 (a replacement key named after the maintainer's framework) |
a maintainer-specific key written into every adopter's .needs.yaml |
| Review bot and labels | internal/forge/pr.go:23, internal/adopt/ruleset.go:95, cmd/standardsctl/sync.go:346, internal/adopt/harness.go:147 |
an unprovisioned private GitHub App requested as reviewer and credited in emitted labels and AGENTS.md (.config/github-app/permissions.md:9-12 states nothing provisions it) |
| Organisation folders | internal/topology/topology.go:33-43, internal/adopt/validate.go:27-37, .config/agent/hooks/block_evasion.py:23 |
the maintainer's organisation names hard-coded as the only recognised containers, in three copies |
| Endpoints, paths, identities | tribunus/internal/sources/litellmgateway/source.go:4-5, internal/harvester/bundle.go:783, internal/devsync/devsync.go:27, internal/supplychain/slsa.go:134, .github/workflows/adopt.yml:79-80, scripts/sync_github_wiki.sh:118-119, .github/workflows/release-binaries.yml:66-89 |
a private gateway, a personal script name, a personal Drive base, a private build-type domain, a private committer identity and legacy asset names |
The engine already has the machinery to carry operator data. Operator settings are layered
(fleet, organisation, deployment, workstation) and merged by internal/config/operator_merge.go;
documents are selected by flag, environment and install manifest through
config.SelectOperatorSettings (internal/config/install_manifest.go:271); and one precedent
already removes an organisation list from code: builtinDevRoot in
internal/agenthook/policy.go:47 names no organisation and takes organisation names from
hooks.command_policy.deny.
A census produced for this record over-counted by treating project identity and the generic
LiteLLM product as private, and proposed an operator.defaults.<name> namespace. That namespace would publish the very names being removed
and add a second configuration system beside the operator settings, so it is not used here.
Decision
1. Layering rule
| Class | Where it lives | Examples |
|---|---|---|
| Project identity | code, unchanged | github.com/cordanaLLM/praetor, the praetor name, SPDX headers, https://cordanallm.github.io/praetor/, funding links (#222) |
| Maintainer's configuration of this repository | this repository's own files | .standards.yaml, mkdocs.yml site_author |
| Operator data (a host's or fleet's choices) | operator settings layers | target framework, default owner, reconcile set, review bot, organisation folders, framework checkout |
| Text in committed adopter artifacts or gate verdicts | no configuration value; the text itself becomes neutral | emitted AGENTS.md harness, label taxonomy, SBOM names, SLSA build type |
The last row has no configuration value because CI runs without the workstation layer: an artifact rendered from an operator value would drift between a local render and a CI re-render (HISS-21).
2. Schema additions
operatorSectionNames (internal/config/operator_decode.go:24) gains framework, forge and
topology. The wildcard lookup in internal/config/operator_schema.go (genericSettingPath,
settingClient) becomes a table of wildcard prefixes so framework.targets.<language> is
validated like clients.selected.<client>. FrameworkLanguages = go, typescript, python, rust,
native is defined once in internal/config and referenced by the needs analyzers and the
harvester (HISS-19).
| Key | Kind | Merge | Default | Validation | Layer | Read by |
|---|---|---|---|---|---|---|
framework.targets.<lang> |
entry | – | absent | <lang> in FrameworkLanguages |
any | needs |
framework.targets.<lang>.module |
text | replace | "" |
module-path shaped (first element contains a dot), at most 256 bytes; the check moves from needs.isModulePathShaped into internal/config |
any | analyzers, report, epic, migrate |
framework.targets.<lang>.builder_kits |
list | replace | [] |
at most 8, each owner/repo; entry 0 is the demand-request routing target |
any | needs requests, harvester |
framework.targets.<lang>.contract |
document path | replace | "" |
clean path, resolved against the declaring layer's file (ResolveOperatorPath); parsed as capability contract v1 when used |
any | declared framework index |
framework.targets.<lang>.checkout |
host path | replace | "" |
clean absolute path; accepted for go only |
workstation | framework observation |
framework.migration_branch |
text | replace | "" (built-in refactor/framework-adoption) |
branch grammar | any | needs migrate |
forge.default_owner |
text | replace | "" |
GitHub owner grammar | any | owner resolution, last step |
forge.reconcile_repos |
list | replace | [] |
at most 256, each owner/repo |
any | issue reconcile default |
forge.review_bot |
text | replace | "" |
owner grammar with optional [bot] suffix |
any | forge.AssignReviewers |
topology.org_containers |
list | append | [] |
at most 64, ^[a-z0-9][a-z0-9._-]{0,63}$, compared lowercased |
any | topology audit, cleanup guard |
The new OperatorSettings fields are tagged json:",omitzero", so the sealed digest of every
existing operator document (EffectivePolicy.SHA256) is byte-identical; a golden test pins this.
DefaultOperatorSettings() leaves every new value empty. One loader,
cmd/standardsctl/operator_settings.go, replaces the copies in hook.go and
client_permissions.go; every command that reads a new key accepts --fleet-config,
--workstation-config and --manifest, the flags clients permissions already uses. MCP tools
select settings from the environment and the install manifest at call time, as hook does.
Capability contract v1 gains optional fields; unknown fields are already ignored
(internal/needs/framework_contract.go:33-35), so the schema version stays 1: ecosystem
(go, npm, pypi, cargo, system; default go; selects the grammar replaces is checked
against), adapts, wraps, tooling_for and top-level foundations. A new command,
praetorctl needs contract export --language=<lang> --out=<file>, snapshots a resolved framework
index as a contract, which also serves offline CI.
Example workstation document (placeholders only):
framework:
targets:
go: {module: example.com/acme/kit, builder_kits: [acme/kit], contract: frameworks/kit.capabilities.yaml, checkout: /home/operator/dev/acme/kit}
forge: {default_owner: acme, reconcile_repos: [acme/kit, acme/app], review_bot: "acme-review[bot]"}
topology: {org_containers: [acme, acme-labs]}
3. Resolution order
- Owner:
--ownerflag, then.standards.yamlrepository.owner, then the origin remote (util.ResolveRemoteIdentity, which never reads the checkout path), thenforge.default_owner, else the errorowner unknown: pass --owner, set repository.owner, or set forge.default_owner. The repository name comes fromrepository.name, then the origin remote, and is never guessed from the directory. One function,config.ResolveRepositoryIdentity, replaces the three resolvers that exist today (paperclip.resolvePlatform,adopt.resolveOwner,cmd/standardsctlresolveRepoCoordinates). - Reconcile set:
--repos, thenforge.reconcile_repos, then the current repository, then the existing refusal. - Framework checkout:
--framework, then$PRAETOR_FRAMEWORK_DIR, thenframework.targets.go.checkout, then the declared contract; the<dev root>/<org>/<repo>fallback incmd/standardsctl/devroot.gois removed.
4. Behaviour when a value is unset
| Feature | Unset behaviour |
|---|---|
needs scan |
classifies dependencies into capabilities; framework is omitted; readiness basis not-configured; the CLI prints Mapping availability: n/a (no target framework configured), never 0% |
needs report, aggregate, MCP standards_needs_report |
classification works; header Framework: not configured (set framework.targets.<lang>.module and .contract, or pass --framework); exit 0 |
needs epic |
preview works with Target framework: not configured; --publish refuses with the same hint |
needs migrate |
dry run prints nothing to rewrite: no target framework configured; --apply refuses |
needs requests |
requests are synthesized; unrouted ones carry target_builder_kit: "" and target_org: "", the spec line Target: unrouted (framework.targets.<lang>.builder_kits not set), and a summary N of M requests unrouted |
| harvester framework scoring | row scored not-configured; builder kits empty |
Owner-dependent commands (issue, milestone, project, init, adopt, forge wiki, paperclip harness) |
resolution order of §3; no invented owner; init writes an empty owner and prints repository.owner not detected; set it in .standards.yaml |
issue reconcile |
§3 order, then the existing refusal |
forge.AssignReviewers |
no bot reviewer |
| Topology audit and cleanup guard | built-in set (upstream, local, stacks, worktrees, scratch) plus topology.org_containers plus structural detection (a non-repository directory in the dev root holding at least one child Git repository); cleanup never deletes a directory that holds a repository |
| Adoption target validation | built-in set plus structural detection (checkForChildRepositories already refuses containers) |
| Agent hooks | the generic dev-root rule of builtinDevRoot; organisation names only through hooks.command_policy.deny |
devsync init |
--base is required; there is no default remote folder |
supplychain provenance |
--builder defaults to the GitHub Actions workflow identity when running in Actions, and is required elsewhere |
5. Compatibility policy
| Old | New | Compatibility |
|---|---|---|
.needs.yaml / JSON replacement key named after the former built-in framework (legacyReplacementKey, internal/needs/demand_alias.go) |
framework_replacement |
read as a deprecated alias when the new key is empty; both set and different is an error; each alias read adds a warning to RepoNeeds.Deprecations, printed by CLI and MCP; writes use the new key only; the alias is removed two minor releases after this record is accepted |
The Go field behind that key (DependencyDemand, CatalogEntry) |
FrameworkReplacement |
none needed: internal/ packages cannot be imported from outside the module |
| built-in framework module, builder kits, catalog replacements | operator configuration and contracts | no built-in fallback after the data unit lands; see §6 |
migrate branch (former framework-specific name, internal/needs/migrate.go:21) |
refactor/framework-adoption |
adopters with an open legacy branch set framework.migration_branch to the old name, which the release note states |
FRAMEWORK_DEMAND.yaml target_org |
unchanged key | value is the owner part of the routing builder kit; empty when unrouted |
.needs.yaml framework: written by an old scan |
– | a scan record, never an input; the next --write replaces it |
forge.StandardReviewBot constant |
AssignReviewers(paths, codeowners, bots) |
no production caller today (tests only) |
topology.KnownOrgContainers, adopt.knownOrgNames |
topology.BuiltinOrgContainers plus configured and structural containers |
one implementation |
Report header naming the former built-in framework (cmd/standardsctl/needs.go:124) |
Framework Migration Report |
text consumers change once |
SBOM assets named after a former private project name (<former>-{cyclonedx,spdx}.json) |
praetor-{cyclonedx,spdx}.json |
renamed without alias; release note |
SLSA buildType URI on an unprovisioned private domain |
a project-owned URI under https://cordanallm.github.io/praetor/ |
verifiers pinning the old value update once; release note |
$PRAETOR_FRAMEWORK_DIR, MCP tool names |
unchanged | – |
Every breaking row is flagged ! in its commit subject with a Migration: footer.
6. Operator data is moved, never deleted
A private value becomes an operator configuration value whose default is unset; an operator who
configures it keeps the feature. Before the built-in framework tables are removed, the data unit
exports them with praetorctl needs contract export into contract files outside the repository,
and proves that nothing is lost: every demand the built-in tables covered is still covered once the
exported contracts are configured. needs scan covers the same demands either way. needs report
can cover more: the built-in report path reconciled every language against the go framework, so
non-go demands its own language's contract maps were reported as gaps. A strict superset
therefore passes; a demand covered before and a gap after fails. The maintainer then records the exports and the former
defaults (owner, reconcile set, review bot, organisation folders, hook deny patterns, GitHub App
manifest values) in their own operator documents. For a maintainer running an operational fork,
.config/fleet.yaml and .config/operator/ are owner-only paths
(internal/operationalsync/overlay.go:23) and are the intended home.
7. Identifier redaction in decision records
Rule 4 of docs/adr/README.md gains one exception: replacing an operator-private identifier (a
repository, organisation, host, path or account) with a neutral placeholder does not change a
decision, and is allowed in an Accepted or Superseded body. Each redaction is listed in a
Redactions note at the end of the record with its date. Decision content, rationale and
evidence bindings stay unchanged.
8. Upstream attribution
Artifacts derived from third-party projects are credited: docs/credits.md lists each derived
artifact with its upstream URL, licence and what changed; REUSE.toml carries an
[[annotations]] entry with the upstream copyright and licence wherever text was copied; each
derived skill records derived_from: <url> (<licence>) in its front matter. Provenance is taken
from the upstream repository itself, never from memory. The maintainer's own projects are not
listed as upstreams.
Consequences
Positive
- An adopter's first run speaks about the adopter's repositories; nothing points at the maintainer's organisation, framework, bot, gateway or folders.
- One owner resolver, one organisation-container list, one label taxonomy, one report header and one settings loader replace duplicated code (HISS-19).
- A fork's framework relationships are matched against its own module prefix; today
internal/needs/framework_observe.go:141andlibrary_relationships.go:40,66strip the built-in module prefix even when a fork is observed.
Negative / Trade-offs
- Until an operator configures
framework.targets, needs reports classify only; the maintainer loses framework coverage between the data unit landing and their configuration existing. - Generated artifacts change once: the emitted AGENTS.md harness block, the label description,
the
.needs.yamlkey,FRAMEWORK_DEMAND.yamltarget_org, SBOM names and the SLSA build type.adoptandsyncreport drift once. - Scripts that ran owner-dependent commands outside a checkout without
--ownernow fail loudly. - Structural organisation detection reclassifies directories in topology audit output.
- About 245 test assertions move to
example.com/acmefixtures through injected targets.
Neutral
- Contract v1 stays at version 1. A praetor release older than this record reading a non-Go
contract rejects its
replacesentries; Go contracts are unaffected.
Verification & Compliance
internal/configreplays operator documents with and without the new sections in both directions (HISS-20); the digest golden test proves existing documents keep their digest.- Each consumer has a test showing that an unset value produces the behaviour in §4.
- The data unit's parity check compares reports and scans from the built-in tables and from the exported contracts before the tables are removed: no covered demand may become a gap.
git grepfor the maintainer's identifiers over code, help text, templates, generated files anddocs/returns only project identity, SPDX headers and this repository's own configuration.
Alternatives considered
operator.defaults.<name>keys (census proposal). Rejected: the key names publish the identifiers being removed, and a second configuration system violates HISS-19.- Keep the built-in tables, active only when the configured module matches. Rejected: the maintainer's data stays published in source.
- Delete the built-in data outright. Rejected: operator data is configured, never deleted (#222); the export in §6 preserves it.
- Exclude superseded records from the documentation site instead of redacting. Rejected: it hides the decision history to hide a handful of identifiers.
- A default owner derived from the praetor module path. Rejected: that is the project's identity, not the adopter's.
Open questions
- Whether the domains named in
.standards.yaml(repository.homepage) and, when this record was written, in.config/github-app/manifest.jsonanddocs/presets/**are project identity or private infrastructure. This record treats them as unprovisioned private domains in emitted and generated surfaces. - Whether this repository's own
.needs.yamlkeeps a framework target. This record rescans it without one, so committed output does not depend on operator configuration. - Resolved 2026-09-27: pull request #193 first added a dependency on the maintainer's framework to
praetor itself, a direction that conflicts with this record. The operator chose a neutral
rewrite instead; #193 now reads
go.modthroughinternal/gomanifestand adds no module.
Checkable clauses
This record declares no adr-constraint block. A forbidden-token clause would have to list the
identifiers it forbids, which publishes them.
References
docs/plans/package-development-pipeline.md(stage 1, and its note that the generic policy supersedes ADR-0007).-
222 (operator data is configuration, never deleted).
- ADR-0007 (superseded on acceptance), ADR-0011 (schedules deletion of
.config/agent/hooks/block_evasion.py), ADR-0012 (owner-only paths of the operational fork). internal/config/operator_decode.go,operator_schema.go,operator_sections.go,operator_merge.go,install_manifest.go;internal/agenthook/policy.go:47.
Redactions
- 2026-09-27: operator-private identifiers replaced with neutral descriptions, in the form
Decision §7 prescribes; no decision content changed. Context table, serialized-key row: the literal key
name became a description. Decision §5, first row: the literal key name became a description
pointing at
legacyReplacementKeyininternal/needs/demand_alias.go, where the alias is still read. Decision §5, SBOM row: the former asset name prefix became<former>. Decision §5, SLSA row: the former build-type URI became a description. Open questions, first item: the domain names became pointers to the files that declare them.