@clossys/controller
v0.10.0
Published
Owns every rule: package lifecycle and process orchestration (catalog, composition, gates, release, repository-profile, review-evidence, workspace-cleanup), account-neutral agent conventions (branch provenance, skill naming, agent interoperability, routin
Maintainers
Readme
@clossys/controller
Control-loop contract
This block is derived from the schema-v5 role contract shipped with this package. The consumer (the client operating the loop) owns its concrete setpoint and review cadence; Controller supplies neither.
Job. Are the business's operating rules expressible, current, and followed?
Metric. rule conformance rate: declared rules independently observed well-formed and followed / all declared rules evaluated (ratio; increase).
Mode. reconcile.
Secondary modes. None.
Stages. sense → judge → act → verify → learn.
Boundary. Owns Operating-rule grammar, identity, lifecycle, and content binding. It excludes judging a proposed change, materializing declared state, authorizing provider mutations.
Close condition. Independent consumer evidence shows the position's owned metric meets its setpoint over the declared review cadence.
Rule conformance rate
The owned metric rule conformance rate is computed by
assessRuleConformanceRate(). An empty evaluated set is indeterminate,
never a perfect rate of 1. This package does not measure consumer evidence
and does not close the loop; a consumer binds the condition against their
own declared rules and independent observations. A green run of this
package's tests is not a close.
Independent consumer evidence shows the position's owned metric meets its
setpoint over the declared review cadence. The owned metric is rule
conformance rate, computed by assessRuleConformanceRate(). Controller
does not invent observations, does not call foundry-check, and does not
judge a proposed change.
controller-check assessment.jsonThe command prints JSON and exits 0 for satisfied, 1 for violated, and
2 for indeterminate, unreadable, or invalid input.
This package declares that command as its first-day assessment surface in its own manifest:
"foundry": { "assessment": { "bin": "controller-check", "invocation": "single-json-input" } }Onboarding discovers that declaration from the installed manifest and never infers a surface. Controller is not a required first-day role; Advisor remains the only required first-day assessment.
This is a rename and a merge, not a rewrite: the former package surfaces are now provided by this package's subpaths. The former package names are retired; new integrations use the current subpaths directly.
Install
npm install @clossys/controllerThis package is published to the public npm registry, https://registry.npmjs.org.
Installing it needs no authentication: no npm token, no .npmrc registry
override, and no GitHub credential of any kind.
Boundaries
controller does not write a scaffold, invoke a declared command, publish a
package, deploy anything, read credentials, or supply a provider setting. A
caller owns all of those actions and values.
planNewPackageis creation planning only: without a profile, it returns a private starter and explicit remaining actions. It does not touch disk. A profile is owned by the consuming repository and supplies its actual metadata, tooling, license text, and dated changelog entry.validatePackageLifecycleis a pure schema check. It distinguishes an incubating source package, a published package, a qualified package, and an adopted package. Deprecated and retired packages need a viable replacement and range (or a terminal no-successor reason), dated evidence, and durable decision and migration references.runGovernanceCheckcalls the included./gatessubpath for real workspace discovery and deterministic build order, then requires the lifecycle registry to match that catalog exactly.preflightGovernedPackagecalls the included./releasesubpath for its packed-install proof and adds the workspace governance result. It does not publish or authenticate. A private registry proof remains the caller's deliberate release operation../cleanup'sclassifyCleanupCandidateperforms no I/O of any kind — no Git, no filesystem, no GitHub, no scheduler, no credential, no network, and no deletion. It classifies caller-gathered evidence and returns a typed proposal; see its own section below for the full boundary, and note in particular that this subpath exports no deletion API at all.
Package-process subpaths
Install @clossys/controller once and import the focused capability
you need. The root exposes lifecycle-registry validation and package-scaffold
planning APIs; it does not declare that a package has completed the
repository's seven-state evidence ladder. That ladder is derived separately
from recorded producer and consumer evidence. The subpaths keep their
established contracts separate without making consumers select many separately
versioned packages.
| Subpath | Includes |
| --- | --- |
| @clossys/controller/catalog | Workspace discovery and dependency-graph evaluation. |
| @clossys/controller/gates | Foundation checks, deterministic build order, a ratchet primitive, override-range and dependency-scope gates, and foundry-check. Does not require typescript — the source-aware secret-surface gates that need it moved to ./gates/secrets (below) as of this version; see "Requirements" for why. |
| @clossys/controller/gates/secrets | Source-aware secret-surface gates: credential inventory, provider-resource naming, local secret files, and raw-secret-read AST detection. Requires typescript — install it to use this subpath; ./gates itself does not need it. Breaking change from earlier versions: these exports used to live on ./gates directly; import them from here instead. |
| @clossys/controller/release | Isolated packed-artifact and installed-import proof. |
| @clossys/controller/repository | Consumer-owned repository profiles, upward requirements, exact-root declarations, pure evaluation, repository-check, the full runner (runRepositoryProfileCheck / repository-profile-check), and one-package repository adoption evidence (RepositoryPackageAdoptionV1 / repository-package-adoption-check). repository-package-adoption-check accepts two forms: the original <adoption.json> <evaluation.json> (unchanged: human-readable, phase-local text) and, as of this version, a single <evaluation-input.json> (the same evaluation input as one file, adoption inlined) that emits one canonical {"state":"satisfied"\|"violated"\|"indeterminate","findings":[...]} JSON line, with exit code 0/1/2 agreeing with state. --help documents both. |
| @clossys/controller/positions | Pure installed-position ledger and completion-evidence validation. foundry-position-check <ledger.json> [role-contract.json] validates positions only; the optional role-contract.json must equal either the role-loop-archetypes.json shipped by this exact version of @clossys/controller, or the exact copy of a role contract a previous version of this package shipped -- currently only 0.9.10's, from before issue #1194's rename and the 0.9.11 @clossys/customer role, matched by deep equality against a small, explicit, embedded table, never a loose comparison. A caller's exact 0.9.10 copy validates against this version's current rules (already compatible with a 0.9.10 ledger, see below) and reports a non-failing legacy-contract-copy advisory naming the matched version (readable as result.advisories for a direct validateInstalledPositionContract call); anything else -- any drift from a known shipped contract, including a historical copy with even one field changed -- is still refused with noncanonical-role-contract, exactly as the exact-match rule always has. validateInstalledPositionLedger's roleContract argument carries the same rule, and so does the exported validateInstalledPositionContract(contract) against installed-position-contract.json (a position contract, not a role contract): a caller's exact 0.9.10 copy of installed-position-contract.json likewise validates with a legacy-contract-copy advisory rather than noncanonical-installed-position-contract, and any other drift is still refused. Drop the argument to use the contract shipped inside this package, or re-copy it from this version. A legacy-format ledger written against 0.9.10 still validates -- precisely, one where no position uses the current learn stageBindings key, and either at least one position uses the pre-rename learnOrEscalate key or the ledger has no positions at all: stageBindings.learnOrEscalate and a missing disposition for a role this package added after 0.9.10 (currently @clossys/customer, added in 0.9.11) are each accepted with a non-failing advisory, never a failure, and foundry-position-check prints any advisory to stderr (stdout for a passing ledger is unchanged: exactly one INSTALLED POSITION LEDGER OK line). A current-format or mixed-vocabulary ledger (any position uses learn) missing that same disposition still fails with missing-role-disposition, exactly as it did before this package accepted legacy ledgers -- the exemption applies only to a ledger that could actually have come from 0.9.10. foundry-completion-evidence-check <completion-evidence.json> <position-ledger.json> validates one linked consumer-retained record for an open position: an exact-version artifact declaration with retained references, recorded invocation and distinct red/green controls, duplicate disposition and rollback records, linked review-cadence evidence, and separately attributed before/after outcome and close-window verdicts. Reference and locator strings have a 65,536-code-unit cap and lexically reject explicit inline sensitive-payload assignments and URL authority userinfo after bounded percent decoding plus NFKC/case/default-ignorable normalization and a separate URL-style tab/CR/LF authority scan; form-style + is treated as a label separator only at root/query/fragment assignment contexts. Ordinary identifiers containing those words remain valid. Evidence instants are known-offset RFC3339 with at most millisecond precision and obey before < invocation/red/green ≤ rollback ≤ after ≤ startedAt < satisfied recurrence ≤ endedAt. A violated or indeterminate in-window cadence result prevents satisfaction. The outcome retains the position baseline and source locator, moves into the linked setpoint, and cannot be owned by the position action authority. References, authorship, provider truth, and adoption are not authenticated or inferred. |
| @clossys/controller/review | Provider-neutral review evidence contracts, validation, and review-check. |
| @clossys/controller/review/github | Pure normalization of caller-provided GitHub-shaped review evidence. |
| @clossys/controller/artifacts | Deterministic, fail-closed verification for a consumer-owned governed artifact: declared kind + schema version, exact-content checksum, and structural provenance. |
| @clossys/controller/cleanup | Pure workspace-cleanup classification: caller-normalized inventory and observations in, a typed owned / safe-candidate / blocked proposal out. No I/O, no deletion API. |
| @clossys/controller/composition | Pure caller-owned cross-plane constraint, supply, decision, exception, and effective-value resolution. |
| @clossys/controller/conventions | Account-neutral agent conventions two parties can share without either owning the other: branch provenance, skill naming, agent interoperability, routine and schedule declarations, CI gate naming, CI conventions and their pure evaluator (ci-conventions-check), and the capability-first skill registry. Ships the documents/adapters/data/templates below as defaults and enforces only their grammar — never byte-identity with its own prose. |
| @clossys/controller/conventions/documents/* | The shipped convention documents themselves (branch-provenance.md, skill-grammar.md, agent-interoperability.md, routine-declaration.md, schedule-declaration.md, live-state-reconciliation.md, skill-registry.md, machine-guidance.md, machine-baseline.md, gate-naming.md, runner-conventions.md, ci-conventions.md) as real files a provisioning step can copy or template onto a machine. |
| @clossys/controller/conventions/adapters/* | The shipped adapter files (agent-policy.rules, shell-integration.zsh, branch-provenance-hook.sh, heavy-cmd-hook.sh, scoped-main-push.sh, workspace-shell.zsh) as real files, same shape as the documents above. |
| @clossys/controller/conventions/data/* | Dated data files an evaluator reads as input, never hard-coded in code: runner-pricing.json (asOf, source URLs). |
| @clossys/controller/conventions/templates/* | Ready-to-adopt CI workflow templates: ci-workflow.yml and product-ci-workflow.yml (a lockfile-selected npm or pnpm workflow for a client product repository), each of which a test in this package proves passes ci-conventions-check as shipped. |
| @clossys/controller/policy | The content-addressed PolicyBinding primitive: compute a digest, validate a binding's shape, verify a binding against materialized content. Zero I/O, zero dependency of its own — the primitive ./gates and ./artifacts bind rules and artifacts to documents with, without ever committing the document itself. |
@clossys/controller/positions exports
validateInstalledPositionLedger, validateInstalledPositionContract,
validateCompletionEvidence, validateCompletionEvidenceContract, and the
corresponding field vocabularies: POSITION_FIELDS,
POSITION_RECOMMENDATIONS, WORKER_COMPONENT_KINDS, REFERENCE_VALUE_RULE,
COMPLETION_EVIDENCE_FIELDS, COMPLETION_EVIDENCE_INDETERMINATE_REASONS,
COMPLETION_VERDICTS, DUPLICATE_STATES, INVOCATION_KINDS, and
PLACEMENT_MODES. InstalledPositionFinding, InstalledPositionAdvisory,
InstalledPositionLedgerReport (its advisories field is optional on the
type only, so a report a caller builds or mocks by hand still satisfies the
interface; every report this package itself returns always sets it,
present as an empty array when there are no advisories and never changing
ok, findings, openRoles, or positions -- it is how a legacy 0.9.10
ledger's accepted migrations are reported),
CompletionEvidenceFinding, CompletionEvidenceIndeterminateReason, and
CompletionEvidenceReport expose pure results. Completion evidence reuses the
shared satisfied / violated / indeterminate result grammar: it validates
the shape and linkage of consumer-retained evidence, refuses the measured
package or position as its own outcome owner, and derives the outcome verdict
from the shipped role's metric direction plus the linked position's setpoint.
Its proof is fail-closed: exact semver and RFC3339 evidence (at most
millisecond precision) must retain the
linked open position's baseline, source locator, and review cadence;
the outcome must move into the role setpoint; and the causal sequence is
before < invocation/red/green ≤ rollback ≤ after ≤ startedAt < recurrence ≤ endedAt.
A violated or indeterminate cadence run in that window cannot be hidden by a
satisfied one. It validates supplied consumer-retained records only; it does
not infer a provider observation from them.
Reference safety is a lexical backstop, not a secret scanner: a reference has
a 65,536-code-unit cap and is examined as the original plus two bounded
percent-decoded NFKC/case/default-ignorable-normalized layers. It rejects
explicit sensitive-category labels (including form-style + label-separator
variants) carrying a nonempty : or = payload. Its URL-authority check uses
only literal delimiters realized in a layer, scans preserved and TAB/CR/LF-
stripped structural views, and considers candidates only at a standalone root
atom or a query/fragment value (including bracketed, quoted, and JSON values).
Path and opaque portions are protected scalar by scalar before the next layer;
literal or unprotected realized encoded Unicode whitespace reopens prose, while
encoded delimiters inside an established protected path remain data. A scheme-looking substring inside an
opaque bare token is not a URL candidate.
When a remaining decode can still establish an authority, query, fragment, or
relative-path boundary, the scanner defers that suffix to the next layer rather
than projecting a premature protected path; on the final bounded layer, actual
delimiters and authority userinfo take precedence. JSON string values admit only their
leading-wrapper and whitespace-delimited URL candidates; JSON punctuation inside
the string remains ordinary text.
Ordinary identifiers that merely contain those
words remain valid. Unicode default-ignorables are removed for this lexical
check and for linked position/action-authority identity comparison; no
reference is resolved or authenticated.
The installed-position ledger keeps its schema-v1 compatibility rule that a
baseline observedAt is nonempty text. Completion validation is deliberately
stricter: that linked baseline must be exactly retained by a readable outcome
observation whose RFC3339 instant has at most millisecond precision before it
can support a completion result.
It never claims a provider observation is true, installs a package, or
measures a provider itself.
The lifecycle vocabulary: absent / found / draft / approved / verified / retired (issue #1228)
One lifecycle vocabulary for every capability and pack item, an owner
decision recorded 2026-09-22: the loop lifecycle (below) and pack item
statuses were designed as two state models side by side before this, using
different words for the same underlying position. LIFECYCLE_STATES (typed
LifecycleState) -- absent, found, draft, approved, verified,
retired -- and LIFECYCLE_CONDITIONS (typed LifecycleCondition) --
current, stale, blocked, shared across every state -- are this
package's own single definition, mirrored word for word by this
repository's own canonical lifecycle contract. This package's own loop engine (below)
uses these states directly; any other package with a status-like surface
imports them from here rather than declaring its own.
A surface that needs a richer vocabulary specializes this one instead of
inventing new words. Pack item statuses are the one example so far:
PACK_STATUSES (typed PackStatus) -- absent, found, draft,
in-review, kept, published -- and packStatusToLifecycle(status)
resolves each one to its PackStatusLifecyclePosition: in-review is
draft with a pending judgment, kept is approved by the Customer keep,
published is verified, sealed and live; the other three map onto the
identically-named state with no extra meaning.
The loop engine: sense / judge / act / verify / learn (issue #1195)
One pure implementation of the loop every role runs, installed in every
staffed repository as an exact dev dependency. Roles declare data -- their
own clossys/<role>/loop.json -- and this package runs them: per issue
#1187's own "who does what" split, packages own definition, judgment, and
deterministic mechanics, and this engine is the deterministic-mechanics
half. LOOP_STAGES (typed LoopStage) is the fixed five: sense,
judge, act, verify, learn -- learn was named learnOrEscalate
before issue #1194's rename; escalation is one of learn's own outcomes,
handing an unresolved problem to the enclosing loop, not a separate stage.
Every role is invoked with loop (/clossys-<role> loop in Claude Code,
@clossys-<role> loop in Cursor); one invocation runs one iteration and
stops at the approval gate inside judge.
Triggers. TRIGGER_KINDS (typed TriggerKind) names what can change --
inputs-changed, role-changed, freshness-window, review-window,
outcome-missed, client-request -- and reentryStageForTrigger(trigger)
/ reentryScopeForTrigger(trigger) (TRIGGER_SCOPES, typed
TriggerScope) are the fixed, total maps from each one to where the loop
re-enters and how much of the role's own capability set that touches. A
freshness window re-enters at judge (the evidence might be stale;
re-decide before acting on it); a review window re-enters at learn (it is
time to adapt or close). isTriggerKind(value) narrows an unknown value.
Blockers. BLOCKER_KINDS (typed BlockerKind) is the fixed five --
missing-input, missing-authority, failing-evidence,
unavailable-environment, contradiction -- each with exactly one owner
in BLOCKER_OWNERS. blockerFor(capabilityId, kind, nextAction, since)
builds one Blocker record with its owner always derived from kind, never
caller-assigned. isBlockerOverdue(blocker, now?) and
overdueBlockers(blockers, now?) are the escalation check: an unparseable
nextAction.byWhen counts as already overdue, never as on schedule. A
blocked capability's own state never touches another capability's --
blocking is per capability, so the rest of a role's loop keeps running.
Staleness. fingerprintInputs(inputs) (each a FingerprintInput) hashes
every supplied input's content into a Fingerprint (path -> sha256 digest,
reusing this package's own content-addressed primitive from ./policy
rather than a second hashing scheme). isStale(recorded, current) is a
deterministic comparison over two fingerprints -- a changed digest, a
dropped path, or an added path are all staleness. changedInputs(recorded,
current) names exactly which paths changed. affectedCapabilities(changedPaths,
capabilityInputPaths) is change propagation: only a capability that
declared a changed path as its own input is ever affected by it.
Artifact operations. isOwnedByRole(role, path) is the one boundary
every operation below shares: a path must fall under that role's own
clossys/<role>/ folder. planCreateOrUpdate(input) returns a
CreateOrUpdatePlan (or a RefusedPlan for a path outside the role's own
folder) -- an update whose file no longer matches this role's own last
write comes back requiresMerge: true, the mechanism behind "a human's
edit is detected by fingerprint and merged, never overwritten."
planMove(input) returns a MovePlan naming every other file whose
content cites the old path, so a move's references are fixed in the same
pull request. planSupersede(input) returns a SupersedePlan: a new entry
is added, the superseded one is named, nothing is proposed for deletion.
planRetire(input) returns a RetirePlan only when the path is one the
role's own manifest lists as an output and nothing depends on it; a listed
path with a live dependent comes back blocked (a BlockedRetirePlan
naming every dependent), and an unlisted path is refused outright rather
than guessed at. Every planning function only ever computes what should
change from already-read state; none of them write a file or open a pull
request -- that is the coding agent's own work, applying an approved plan,
the same boundary @clossys/launcher already draws for composing skills.
Stale-plan refusal. "A plan is bound to the assessment it came from;
if that assessment changes, the plan is re-proposed as a diff and never
executed as written." bindPlan(plan, boundFingerprint) attaches the
fingerprint a plan (any of the artifact-operation plans above, or a
caller's own) was computed from. decidePlanExecution(binding,
currentFingerprint) is the one place that compares it against the
current inputs before anything executes: execute carries the plan
through, stale-refuse (PLAN_EXECUTION_OUTCOMES, typed
PlanExecutionOutcome) carries null instead, so a caller that reads a
stale plan's contents off the decision has necessarily skipped the check
this module exists to enforce. Returns a PlanExecutionDecision.
State and status. validateLoopState(value) validates a parsed
clossys/<role>/loop.json document (a LoopState, keyed by capability id
to a LoopCapabilityState) and returns every LoopStateFinding;
isValidLoopState(value) narrows. resumeStage(capability) returns a
capability's own recorded stage exactly as written -- an interrupted run
resumes from disk, never from re-derived or guessed position.
renderStatusDocument(role, sections) is the generic five-section
renderer -- StatusSections: Mandate / Where we are / Recommended next /
Decisions / Blockers, in that fixed order -- deliberately decoupled from
LoopState so the Advisor lane's own parallel STATUS document (written in the
same wave, with the same five sections) stays compatible with this
renderer taking it over later. renderLoopStatus(role, state, mandate,
now?) is this package's own use of it, building those five section bodies
from one role's loop state. The installed foundry-loop-status
<loop.json> <mandate.txt> [--out <STATUS document path>] executable is the CLI
form, on the same 0 / 2 ternary as this package's other gates (there is
no 1: a report is rendered or it is not).
The shared check-output-envelope (issue #1174)
The repository contract docs/contracts/check-output-envelope.json,
which does not ship with this package, is one JSON report shape for
every check command's report, across every role -- shipped in Stage A with
no real emitter yet. buildCheckOutputEnvelope(options) is the first one:
it builds a CheckOutputEnvelope (package, version, verdict,
summary, findings: CheckFinding[], an optional metric: CheckMetric
and nextAction, all typed from BuildEnvelopeOptions), refusing at
construction time to build a non-satisfied verdict with an empty
findings list -- a report that says something is wrong while refusing to
say what it is. envelopeToExitCode(envelope) folds the envelope's own
verdict onto this package's 0 / 1 / 2 exit-code convention, reusing
the same GateVerdict vocabulary ./gates already declares rather than a
second copy. The schema-version and heartbeat checks below are its first
two real emitters.
Schema versions and migrations for every clossys/ record (issue #1224)
Every record a package writes under clossys/ carries a schemaVersion;
this module is the deterministic migration engine every package's own
forward migrations run through. classifyRecordVersion(table, record) is a
read-only answer -- RecordVersionClassification: already-current /
migratable / future / no-path -- over one record against a
MigrationTable (kind, currentVersion, and an ordered list of
MigrationSteps, each a pure fromVersion -> toVersion hop).
migrateRecord(table, record) actually walks the chain, returning a
MigrationOutcome: AlreadyCurrentOutcome (idempotent -- re-running this on
an already-migrated record is a no-op), MigratedOutcome (every applied
step's description, plus backup, the record exactly as it was before any
step ran), or IndeterminateOutcome (a missing/non-numeric schemaVersion,
a schemaVersion newer than this package knows -- never downgraded --
or a gap in the step chain; the record comes back byte-identical in every
case, never partially migrated).
createRecordKindRegistry() returns an empty, open RecordKindRegistry any
caller populates with its own tables; defaultRecordKindRegistry() is the
one this package's own CLI uses, seeded with the two record kinds actually
found under clossys/ on main today -- LOOP_STATE_KIND
(clossys/<role>/loop.json) and COVERAGE_DECLARATION_KIND
(clossys/coverage.json) -- an extension point, not a closed list: a
package like Strategist registers its own table into its own registry
instance when it adopts this engine.
discoverRecords(repoRoot, locations?) walks <repoRoot>/clossys/ for
files matching a known RecordLocation (DEFAULT_RECORD_LOCATIONS, the
same two kinds above) into a list of DiscoveredRecords, skipping
clossys/.state/ (generated files only, never a source record). runMigrations(repoRoot, registry, options?)
(RunMigrationsOptions) classifies/migrates every discovered record into a
RecordMigrationReport, and -- only with apply: true -- writes the
migrated record back to its own path plus a backup of its pre-migration
bytes at clossys/.state/schema-backups/<relative-path>.v<oldVersion>.json.
The installed foundry-schema-migrate [repoRoot] [--apply] executable is
the CLI form: report-only (dry run) by default, emitting one
CheckOutputEnvelope (see above) -- indeterminate if any record could not
be classified, satisfied otherwise (a migrated record still counts
satisfied; the gate is about a record shape shipping with no migration at
all, not about migrations never running).
Operating cadence: a zero-token heartbeat (issue #1221)
A business runs continuously, but a loop only runs when a person types
/clossys-<role> loop. The heartbeat closes that gap without a model:
computeHeartbeat(roles, now?) deterministically finds every capability,
across a Readonly<Record<string, LoopState>> of roles, that is
blocked-capability (one entry per open blocker, overdue reused directly
from ./blockers's own isBlockerOverdue rather than a second copy),
pending-decision (stage judge), stale-capability (condition stale),
or review-waiting (stage learn) -- the fixed four HeartbeatFindingKinds
in HEARTBEAT_FINDING_KINDS, each one a DigestEntry, joined into one
HeartbeatDigest sorted overdue-blockers-first, then pending decisions,
then the rest, ties broken by role then capability id for determinism.
renderDigest(entries, now?) is a plain, mechanical Markdown renderer, in
the same generic-renderer style as renderStatusDocument above -- Advisor's
own later wording/prioritization pass supersedes it, per this feature's own
ownership split (engine and computation here, installing the workflow is
Launcher's job, digest wording is Advisor's). "Nothing waiting" renders one
plain line, never an empty file.
loadLoopStates(repoRoot) reads every clossys/<role>/loop.json under a
repository root into a LoadedLoopStates map, validating each with
isValidLoopState and reporting an unreadable or invalid one as an
UnreadableLoopState rather than throwing (a role directory with no
loop.json is silently skipped -- it has not adopted the loop engine yet).
computeHeartbeatForRepo(repoRoot, now?) composes that read with
computeHeartbeat into one HeartbeatRunResult, and
writeHeartbeatDigest(repoRoot, digest, now?) renders the decisions file
into the consumer repository's own state directory, at the path named by
the exported HEARTBEAT_DIGEST_PATH constant -- kept as a separate write
step so a caller can run in report mode by simply not calling it. The installed foundry-heartbeat [repoRoot] [--write]
executable is the CLI form: report mode by default, --write also renders
the digest file, emitting one CheckOutputEnvelope -- satisfied whenever
the digest computed successfully (a populated digest is not itself a
violation), indeterminate only when a loop.json could not be read or
validated. Never calls a model; never makes a live external change.
controllerHeartbeatSchedule(scope) builds the reference
ScheduleDeclaration (id controller-heartbeat, a business-days-only
cadence, artifact: "scripts/run-heartbeat.mjs" -- this repository's own
demonstration wrapper, which does not ship with this package) this
package's existing schedule conventions already define -- "work that runs
without a model is a schedule, never a routine." validateHeartbeatSchedule(declaration,
registry) is a thin, named call to the existing
validateScheduleDeclaration, so a caller never re-derives that validation
by hand. A declaration is not a deployment: installing the workflow that
actually runs this on a clock is a consuming repository's own job (via
@clossys/launcher), not this package's.
First-day onboarding: discovering and invoking role-owned assessments
One parameterized workflow that discovers and invokes role-owned assessments for a consuming repository's first day. It is orchestration, and deliberately nothing more: it carries no assessment content, and it is not a substitute for any role it opens.
It reaches consumers two ways: as the installed foundry-onboarding-run
executable, and as named exports on this package's root entry point
(@clossys/controller) rather than a subpath of its own. That placement is a
constraint, not a preference. The frozen public-npm aggregate canary plan pins
an immutable optional-peer matrix covering every declared export specifier of
every package carrying an optional peer, and that plan may not be rewritten —
so a new subpath on this package has nowhere to be recorded. The root
specifier is already in that matrix and its recorded imports outcome stays
truthful, because nothing in this module needs typescript. Declaring the
subpath in a way the matrix could not see would have been the other option,
and would have been a false green.
runFirstDayOnboarding(request, options) does four things in order.
- Select.
selectRolesopens a role when — and only when — one of the fixedSELECTION_RULESfires over facts the consumer declared:engagement-baselinealways opens the advisory role;unresolved-directionopens the direction role when the request declares any ofbusiness-model,value-formula,northstarsorcausal-metric-treeunresolved;unmapped-operating-systemopens the operating-system role when it declaresontologyorrepository-topologyunmapped;independent-outcomealways opens the outcome role; andconsumer-requestedopens any operating role the consumer named. Every other active role is excluded under the single reasonno-selection-rule-opened-this-role. Deciding whether a direction question is unresolved is expertise; reading a declaredunresolvedlist is arithmetic, and only the second happens here. - Discover.
discoverRoleAssessmentSurfacereads the role's own installed manifest for its own declaration —"foundry": { "assessment": { "bin": "<binName>", "invocation": "single-json-input" } }— and resolves it against that same manifest'sbinmap. It never infers a surface. A role that ships five gate CLIs and declares none has no assessment surface, because choosing which of the five is the assessment would be this package deciding what another role's assessment is. - Invoke.
observeRoleAssessmentruns the resolved executable with no shell, no caller-supplied command, argument list or executable path, a bounded deadline, and registry-credential environment variables removed. It parses what the role printed and carries it onward untouched: an unparseable answer isassessment-output-unreadable, never an empty assessment, and a printedstatethat disagrees with the exit code isassessment-exit-inconsistent. - Join.
joinFirstDayOnboardingproduces the report: which roles a rule opened, what each one's own surface returned, and what could not be observed. That is the whole of its output.
Two constraints, enforced structurally rather than by convention
The workflow carries no assessment content. A role's assessment is typed
unknown throughout this subpath, so no code here has a vocabulary with which
to construct one that typechecks as content. assertPassThroughAssessments
re-checks that by reference identity before any report is returned: a
record whose assessment is not the very object the role returned — because
it was authored, defaulted, normalized or merged — throws rather than ships.
assertRoleAuthoredPositions applies the same test to every position in a
proposed ledger.
The workflow claims no role's expertise. Its report has no baseline,
target, setpoint, causalHypothesis, recommendation, criticalPath or
openQuestions field of its own; those appear only inside a role's returned
assessment. A role's violation stays the role's finding. And the run state
is derived, never asserted: satisfied requires that every selected role
returned its own assessment cleanly, indeterminate dominates violated, and
a run that opened no role at all is indeterminate rather than vacuously
clean.
A role with no assessment surface is a result, not a skip
Each AssessmentSurfaceAbsence — package-not-installed,
manifest-unreadable, no-assessment-declaration,
invalid-assessment-declaration, undeclared-assessment-bin,
assessment-executable-missing — and each AssessmentInvocationFailure —
assessment-input-missing, assessment-not-executed,
assessment-output-unreadable, assessment-exit-inconsistent,
assessment-timed-out — reaches the report as a named gap against the role
that was opened, with the rule that opened it. Any gap makes the run
indeterminate. A capability that could not be observed must not grade
identically to one that was observed and was fine, so an unassessed role and
an assessed clean role never produce the same exit code.
Producing the contract, and refusing mutation without it
proposeInstalledPositionLedger(run, activeRoles) derives one complete
installed-position ledger covering every active role: dispositions from the
deterministic selection, positions copied by reference from each role's own
proposedPositions. It returns ledger: null whenever a selected role
produced no assessment. There is no "assume not-applicable" path — deciding
that an opened role is not needed is that role's call or the decision owner's.
authorizeMutation(run, ledger, approval) is the gate. It authorizes nothing
unless the run is satisfied with no gaps, the ledger validates under
validateInstalledPositionLedger — which is what makes baseline, setpoint,
authority, guardrails and escalation path mandatory on every open position —
and this engagement's own decision owner has approved that exact run by
onboardingRunDigest. A stale digest, a different approver, a missing
baseline evidence reference or an absent action authority each refuse.
foundry-onboarding-run <request.json> <install-root> <evidence-dir>
[--report <path>] [--ledger <path>] is the installed entry point, on the same
0 / 1 / 2 ternary as every other gate here. The active role set comes
from the role contract shipped beside this package, never from a list
maintained in this subpath.
Extended manifest discovery: intake, outputs, status, fit, solves, needs, feeds (issues #1172, #1176)
discoverRoleAssessmentSurface above reads one manifest key,
foundry.assessment. Issue #1172 extends the same discipline to four more
role-owned manifest keys, and issue #1176 (owner decision recorded
2026-09-22: "kits are composed, not a fixed grouping") adds three more as
schema version 2. Every one of these seven fields is discovered the same
way: read only the role's own installed manifest, never infer a surface from
what the package ships, and report absence as a named, determinate value
rather than guessing or silently skipping.
foundry.intake(INTAKE_DECLARATION_PATH) — a package-relative path to the role's own shipped intake-question-cards file, discovered bydiscoverRoleIntakeSurface/discoverRoleIntakeSurfacesinto anIntakeSurface. Absence reaches the caller as one ofINTAKE_SURFACE_ABSENCES(IntakeSurfaceAbsence).foundry.fit(FIT_DECLARATION_PATH) — the same shape, for the role's own shipped fit-signal-declarations file.discoverRoleFitSurface/discoverRoleFitSurfacesresolve aFitSurface; absence is one ofFIT_SURFACE_ABSENCES(FitSurfaceAbsence).foundry.status(STATUS_DECLARATION_PATH) — a read-only status probe, declared and resolved exactly likeassessment:{ bin, invocation }resolved against the manifest's ownbinmap.discoverRoleStatusSurface/discoverRoleStatusSurfacesproduce aStatusSurface; absence is one ofSTATUS_SURFACE_ABSENCES(StatusSurfaceAbsence).foundry.outputs(OUTPUTS_DECLARATION_PATH) — the paths a role declares it owns.discoverRoleOutputsDeclaration/discoverRoleOutputsDeclarationsresolve anOutputsDeclaration, and enforce the one structural rule #1171's layout requires: every declared path must fall under that role's ownclossys/<role>/folder. A path outside it is not silently kept — it is reported as the dedicated absenceoutput-path-outside-role-folder(OUTPUTS_DECLARATION_ABSENCES,OutputsDeclarationAbsence).
Schema version 2 (issue #1176) adds three more manifest keys, discovered the
same manifest-only, shape-level way. None of the deeper cross-file checks —
resolving solves.problem against a client-problem vocabulary, matching
solves.metric or solves.proofCase against this role's own owned metric
or qualification adapter, matching a needs entry against some role's
feeds entry, or detecting a cycle in the resulting needs/feeds handoff
graph — happen in this package. Those are this repository's own dev-time
questions, answered by this repository's own gate script in its --enforce
mode, not ones a runtime orchestration can answer for an arbitrary consumer.
foundry.solves(SOLVES_DECLARATION_PATH) — a list of verifiable claims about the client problems this role solves, each aSolvesEntry(problem,statement,metric,proofCase, and anevidencelevel drawn fromSOLVES_EVIDENCE_LEVELS/SolvesEvidenceLevel:designed,qualified,proven).discoverRoleSolvesDeclaration/discoverRoleSolvesDeclarationsresolve aSolvesDeclaration; absence is one ofSOLVES_DECLARATION_ABSENCES(SolvesDeclarationAbsence).foundry.needs(NEEDS_DECLARATION_PATH) — artifacts this role consumes from another role's ownfeeds, each aNeedsEntry(producerRole,artifact).discoverRoleNeedsDeclaration/discoverRoleNeedsDeclarationsresolve aNeedsDeclaration; absence is one ofNEEDS_DECLARATION_ABSENCES(NeedsDeclarationAbsence).foundry.feeds(FEEDS_DECLARATION_PATH) — artifacts this role produces for other roles, each aFeedsEntry(artifact,path), under the sameclossys/<role>/ruleoutputsalready enforces.discoverRoleFeedsDeclaration/discoverRoleFeedsDeclarationsresolve aFeedsDeclaration; apathoutside the role's own folder isfeeds-path-outside-role-folder, and any other malformed entry is one ofFEEDS_DECLARATION_ABSENCES(FeedsDeclarationAbsence).
./artifacts: governed artifact verification
A reusable contract for verifying a consumer-owned governed artifact that combines a declared kind + schema version, an exact-content checksum, and structural source/revision provenance — closing the gap issue #195 opened over: a checksum could pass while the schema version was unsupported, or provenance could be attached without ever being checked.
import { verifyGovernedArtifact, verifyGovernedArtifacts } from "@clossys/controller/artifacts";
import type { GovernedArtifactManifest, GovernedArtifactVerificationOptions } from "@clossys/controller/artifacts";
const manifest: GovernedArtifactManifest = {
kind: "widget-catalog",
schemaVersion: "2",
checksum: { algorithm: "sha256", digest: "…64 lowercase hex characters…" },
provenance: { source: "https://example.invalid/repo", revision: "abc123" },
};
const options: GovernedArtifactVerificationOptions = {
artifactKind: "widget-catalog",
supportedSchemaVersions: ["1", "2"],
};
const findings = verifyGovernedArtifact(manifest, rawContentBytes, options);
if (findings.length > 0) process.exitCode = 1;The verification order is fixed, deterministic, and documented
verifyGovernedArtifact runs five stages, always in this order, and
short-circuits on the first stage that reports an error:
- Caller options —
artifactKindnon-empty,supportedSchemaVersionsnon-empty. An emptysupportedSchemaVersionsis a caller configuration error, never an artifact that trivially passes. - Manifest structure, including provenance shape (presence and shape only — folded into the one mandatory stage every successful verification passes through, so provenance can never be attached without being checked).
- Artifact kind —
manifest.kindmust equaloptions.artifactKind. - Schema version —
manifest.schemaVersionmust be one ofoptions.supportedSchemaVersions. Checked before the checksum, deliberately: this is the exact ordering #195 was opened over. An unsupported schema version is rejected even when the bytes match exactly. - Exact-content checksum — delegated entirely to
@clossys/controller/policy's ownverifyBinding; this package hashes nothing itself. Checked last, both because it is the most expensive check and because checking it last means a caller can never see a passing checksum for an artifact whose kind or schema version were never actually accepted.
verifyGovernedArtifact returns [] only after all five stages ran and
every one produced zero error findings — there is no path that returns []
having skipped a stage. See src/artifacts/verify.ts's own doc comment for
the full reasoning and src/artifacts/verify.test.ts for tests that use
fixtures broken at more than one stage simultaneously to prove only the
earliest stage's findings are ever reported.
verifyGovernedArtifacts(entries, options) verifies a batch sharing one
trust configuration, prefixing each finding's path with that entry's own
id. An empty entries array is itself a failure
("artifact/empty-batch"), never a clean [] — the same
"a check that cannot run must fail" discipline documented in
CONTRIBUTING.md (precedent: commit 01bd520).
What a clean result proves, and what it does not
A clean result proves the manifest is structurally well-formed (including
provenance), its kind and schemaVersion are both accepted by the
caller, and content is byte-for-byte identical to what the checksum
committed to. It never proves the payload is semantically valid under
that schema version (schema-specific validation stays entirely
caller-owned), that provenance.source/provenance.revision are genuine
or that the named revision actually produced this content (only their
shape is checked, never their truth), or — the sharpest distinction —
who produced the content. Matching content is not attribution: a
checksum proves bytes are unchanged from what was committed to, never who
committed to them. This subpath implements no signature or
identity-attestation scheme.
Digest comparison is delegated, not reimplemented
checksum.algorithm is typed as @clossys/controller/policy's own
DigestAlgorithm — currently just "sha256" — rather than a second,
independent union, so this contract can never claim to accept an algorithm
policy itself does not support. Both the digest's SHAPE (is it the right
number of lowercase hex characters for its algorithm) and its VALUE (does
it match content) are checked by handing a small synthetic
PolicyBinding to policy's own validateBindingShape/verifyBinding —
this package never re-derives which algorithms are known, how long a
digest should be, or how to hash anything.
Fail-closed vocabulary
Every finding is a Finding from @clossys/controller/policy, shaped
{ rule, severity, message, path? } and re-exported from this subpath.
Rules prefixed artifact/ are owned here; policy-id-shape,
digest-algorithm-known, digest-shape, and digest-mismatch are
@clossys/controller/policy's own rule names, passed through verbatim so a
caller can see exactly which layer reported the problem. See
GovernedArtifactFindingRule (documentation-only — Finding.rule itself
stays plain string) for the full vocabulary.
The package owns structural metadata validation and deterministic
orchestration only. The consumer owns artifact bytes, semantic decoding,
schema-specific validation, storage, transport, and trust policy —
including deciding what provenance.source/provenance.revision actually
mean and whether to trust them.
./gates additions: ratchet, override bounds, dependency scope, gate-result ternary
Three small, independent, pure gates alongside the existing foundation,
build-order, and policy checks — none of them do I/O; a caller reads
whatever real data each one needs and passes it in. A fourth addition, the
gate-result ternary below, is not a gate itself but the shared vocabulary
the other three (and foundry-check's own CLI) already independently
converged on. A fifth, adaptLegacyCheckResult, folds a pre-existing,
non-GateResult check outcome onto that same ternary. (The source-aware
secret-surface checks that used to be listed alongside these now live at
./gates/secrets instead — see "Requirements" below for why they moved.)
The gate-result ternary: satisfied / violated / indeterminate
Every gate result in this repository is exactly one of three states, never
collapsed into a binary pass/fail: satisfied (evaluated, condition
holds), violated (evaluated, condition does not hold), or indeterminate
(could not evaluate — fails closed, and carries a required, machine-readable
reason). This is not new: foundry-check's own CLI already ships this
ternary as its 0/1/2 exit-code contract, evaluateRatchet's status: "clean"
| "regression" | "invalid" is the identical three states under different
names, and Designer, Writer, and Strategist each independently
reinvented a third shape (unchecked: UncheckedItem[], non-empty meaning
"cannot vouch for this scan"). GateResult is that shape, named once, so
the next gate reuses it instead of reinventing it a fifth time.
import {
createGateReasons,
foldGateResults,
gateResultToExitCode,
gateSatisfied,
gateViolated,
} from "@clossys/controller/gates";
// A gate declares its own finite, reviewable set of indeterminate reasons.
const reasons = createGateReasons(["missing-credential", "no-applicable-inputs"] as const);
function evaluateOne(input: RegistryProbe) {
if (input.token === undefined) return reasons.indeterminate("missing-credential");
return input.registryReachable ? gateSatisfied(1) : gateViolated(["registry unreachable"]);
}
const overall = foldGateResults(probes.map(evaluateOne), { emptyReason: "no-applicable-inputs" });
process.exitCode = gateResultToExitCode(overall); // 0 satisfied / 1 violated / 2 indeterminate, unconditionallygateSatisfied(evaluated) refuses to construct a passing result unless
evaluated is a positive integer — the mechanical form of the meta-check
this contract exists to enforce: a gate built on this function cannot
report a pass on a code path that evaluated nothing. createGateReasons
scopes indeterminate() to exactly the reasons a gate declares up front;
naming an undeclared reason throws rather than silently widening what
"indeterminate" means for that gate. gateResultToExitCode takes no
override — indeterminate always maps to 2, never 0, regardless of
reason; see this function's own doc comment for why that is a deliberate,
non-configurable choice. assertNeverVacuouslySatisfied(evaluate, input)
is a reusable regression-test helper: call it with an input engineered to
produce no real evaluation and it throws if the gate under test reports
satisfied anyway. gateResultFromRatchet(result) converts an existing
evaluateRatchet result into this shape, as a worked proof that the two
are the same contract rather than parallel ones.
adaptLegacyCheckResult(legacy, options)
Folds a pre-existing, non-GateResult check outcome onto this same
ternary. A repository rarely adopts GateResult for every check on day
one — more often, one existing script already reports its own ad hoc
pass/fail/can't-tell shape, and a caller further up (an observation
transport, a report aggregator, a CI summary) wants to fold that outcome in
next to every other GateResult-native check without rewriting the
original script. This has been hand-written more than once by consumers of
this package: a short switch over the legacy check's own three states, a
per-finding severity remap (a legacy check's own severity words are almost
never the caller's own Finding vocabulary), and a required fallback
finding for the one case gateViolated itself refuses — reporting a
violation with nothing wrong in it.
import { adaptLegacyCheckResult } from "@clossys/controller/gates";
type LegacySeverity = "error" | "warning" | "info";
const legacyResult = runMyExistingCheck(); // { verdict: "violated", findings: [{ severity: "error", message: "..." }] }
const result = adaptLegacyCheckResult(legacyResult, {
severityMap: { error: "high", warning: "medium", info: "low" } as Record<LegacySeverity, "high" | "medium" | "low">,
defaultSeverity: "high",
fallbackMessage: "myExistingCheck reported violated with no findings.",
});legacy must already be reduced to the three verdict tags GateResult
itself uses ("satisfied" | "violated" | "indeterminate") — translating a
genuinely bespoke result shape (its own status/ok fields, its own
nesting) down to that is the caller's own job, since it is the one part
that is truly different for every legacy check. Everything past that point
is common: a "satisfied" legacy result with no evaluated count becomes
gateSatisfied(1) (matching gateResultFromRatchet's own convention for
an equivalent single-question check); a "violated" legacy finding's
path, when present, is folded into message; and severityMap /
defaultSeverity are fully generic over whichever severity vocabulary the
caller's own Finding type uses — this function never assumes one severity
vocabulary is universal.
evaluateRatchet(current, baseline)
A generic "warn-first with a checked-in baseline, ratchet monotonically
toward zero" primitive — count whatever a caller wants to track down to
zero (lint warnings, TODOs, any usages, anything), read a checked-in
baseline, and call this with both numbers.
import { evaluateRatchet } from "@clossys/controller/gates";
const result = evaluateRatchet(currentWarningCount, baselineFromDisk);
if (!result.ok) process.exitCode = result.status === "invalid" ? 2 : 1;| current vs baseline | ok | status | Notes |
| --- | --- | --- | --- |
| current === baseline | true | "clean" | improved: false, no findings. |
| current < baseline | true | "clean" | improved: true plus a "ratchet/baseline-stale" warning finding — real progress, reported explicitly, never silently dropped. Lowering the baseline is always a separate, explicit action; this function never does it for you. |
| current > baseline | false | "regression" | A "ratchet/regression" error finding. |
| either input is nonsense | false | "invalid" | A negative or non-integer current/baseline, or a missing (undefined/null) baseline, fails closed — this is "could not run", not a clean or regressed result, and current/baseline are not echoed back. |
checkOverrideTargetRanges(overrides)
A package.json overrides entry's target range must be upper-bounded to
the vulnerable major — never a bare >=x.y.z. An unbounded target lets a
resolver hoist a dependent across a major version boundary and break it at
runtime; a security audit cannot catch this class of break, since an audit
only confirms the vulnerable version is gone, never that the replacement
stays API-compatible with what depends on it.
import { checkOverrideTargetRanges } from "@clossys/controller/gates";
checkOverrideTargetRanges({ "left-pad": ">=1.2.3" });
// -> one "overrides/range-unbounded" finding
checkOverrideTargetRanges({ "left-pad": ">=1.2.3 <2.0.0" });
// -> []Range parsing is hand-rolled (no semver dependency) and deliberately
narrow. It recognizes exactly: an exact pin ("1.2.3", "=1.2.3"), a ~
or ^ range, an explicit space-hyphen-space range ("1.2.3 - 2.0.0"), and
a single or paired >=/>/</<= comparator range. Anything else — OR
ranges ("... || ..."), x-ranges/wildcards, dist-tags, git/file/workspace
specifiers, three or more space-separated comparators — is reported as
"overrides/range-unparseable", a finding, not a pass: an unparseable
range is exactly the case where this gate must not assume the best.
checkDependencyScope(catalog, scope, allowlist, options?)
Mechanical enforcement of CONTRIBUTING.md's
"Dependencies: the default answer is no": every dependencies entry in a
packages/*/package.json must be <scope>/*-scoped, unless it is named in
a small, checked-in allowlist entry.
import { buildCatalog } from "@clossys/controller/catalog";
import { checkDependencyScope } from "@clossys/controller/gates";
const catalog = buildCatalog(process.cwd());
const allowlist = JSON.parse(readFileSync("dependency-scope-allowlist.json", "utf8"));
const findings = checkDependencyScope(catalog, "@example", allowlist);| Allowlist entry field | Type | Notes |
| --- | --- | --- |
| name | string | The exact, non-scoped dependency name being exempted. Non-empty. |
| reason | string | Why this dependency was deliberately admitted. Non-empty. |
| reviewBy | string | YYYY-MM-DD. Once passed, the entry stops exempting anything and is itself reported as "dependency-scope/allowlist-expired". |
{ "version": 1, "entries": [] }A malformed allowlist document or entry is a finding
("dependency-scope/allowlist-shape" / "dependency-scope/allowlist-entry-shape")
and exempts nothing — never a silent exemption. Deliberately scoped small:
every runtime dependency in this repository was verified by inspection to
already be @clossys/*-scoped, so this is a floor that matches
that reality today, not a full dependency admission-and-retirement
register; it can grow richer if a third-party runtime dependency is ever
legitimately admitted.
./repository: profiles, requirements, and exact roots
@clossys/controller/repository owns a strict grammar and pure,
deterministic evaluation. It ships no profile, root entry, requirement,
observation, repository inventory, machine value, precedence rule, retention
decision, or default. It performs no filesystem, Git, provider, scheduler,
credential, installation, or mutation I/O. A caller owns discovery and every
name and value supplied to the validators.
Importing either this public subpath or its importable CLI API is inert: it
does not inspect the filesystem or process arguments, write output, change an
exit code or environment value, or mutate arguments. The installed
repository-check command enters through a separate executable-only wrapper;
only an explicit command invocation reads the requested profile and reports.
Canonical declaration location (issue #315)
A consumer's declaration lives at governance/repository-profile.json —
CANONICAL_REPOSITORY_PROFILE_PATH, exported alongside the schema version
constants below. This is settled, not merely a convention: an aggregator has
to know where to look, and "wherever that repository decided" defeats the
point of packaging one evaluator for every consumer to share.
Locating a declaration is repository-check's job, not this subpath's — the
pure library still performs no I/O. Run with no argument, or with a directory
argument, and the CLI searches that root for a declaration without being told
exactly where it is:
$ repository-check # searches the current working directory
$ repository-check path/to/repository # searches an explicit repository root
$ repository-check path/to/profile.json # validates exactly that file, no searchThe canonical path is always checked first and, when present, is always what
gets used — nothing found elsewhere can shadow it. When nothing is at the
canonical path, the search continues for a declaration parked somewhere else
(the canonical filename under another directory, or a known former filename
under the canonical directory). A declaration found there is never reported
the same way as no declaration at all: it produces its own
declaration-non-canonical-location finding, distinct from
declaration-not-found, so a repository that has a declaration in the wrong
place is never read as a repository that declares nothing.
Requirements flow one way: a repository declares what it needs, an account workspace discovers and aggregates those declarations, and machine bootstrap may use the resulting report to compose an explicit configuration. Guidance and mutation do not flow upward through this API. If a caller later chooses to apply a configuration, it resolves that manifest itself and passes it to a separate engine; this subpath neither produces nor applies one.
Repository-root vocabulary belongs here because it describes the direct
children of one repository. Account-container discovery and composition do not:
they coordinate multiple repositories and stay caller-owned. For that reason
this package adds no broad top-level workspace package and no
@clossys/controller/workspace subpath.
Profile schema v3
import {
REPOSITORY_PROFILE_VERSION,
validateRepositoryProfile,
type RepositoryProfileV3,
} from "@clossys/controller/repository";
const profile: RepositoryProfileV3 = {
schemaVersion: REPOSITORY_PROFILE_VERSION,
defaultBranch: "main",
releaseBranch: "release",
commands: [{ name: "check", run: "npm run check" }],
protectedPaths: [".github/workflows/**"],
requirements: [
{
id: "runtime.example",
scope: "machine",
constraint: { kind: "one-of", values: ["variant-a", "variant-b"] },
},
{
id: "tool.formatter",
scope: "repository",
constraint: { kind: "present" },
},
{
id: "runtime.node",
scope: "machine",
constraint: { kind: "minimum-version", floor: "20" },
},
],
rootEntries: [
{ name: "source", classification: "canonical", disposition: "required" },
{ name: ".tooling", classification: "extension", disposition: "allowed" },
{ name: "special-case", classification: "exception", disposition: "allowed" },
{ name: "old-link", classification: "compatibility-alias", disposition: "prohibited" },
{ name: "archive", classification: "legacy-artifact", disposition: "allowed" },
],
};
const findings = validateRepositoryProfile(profile);| Field | Type | Notes |
| --- | --- | --- |
| schemaVersion | 3 | New declarations use REPOSITORY_PROFILE_VERSION. The closed v1 and v2 shapes remain accepted deliberately; see compatibility below. |
| defaultBranch | string | A valid Git branch name. |
| releaseBranch | string (optional) | The separate long-lived branch this repository deploys from, when it has one. A valid Git branch name, and never equal to defaultBranch. v3 only — see below. |
| commands | RepositoryCommand[] | Ordered, dense array (at most 10,000 entries); names are unique. |
| protectedPaths | string[] | Ordered, dense repository-relative paths or the supported */** patterns; duplicates are rejected. |
| requirements | RepositoryRequirement[] | Ordered, dense array of unique (scope, id) declarations. Foundry supplies no entries. |
| rootEntries | RepositoryRootEntry[] | The caller's exact direct-child vocabulary. Names are unique single path segments; every entry has an explicit classification and disposition. |
The branch topology, and the exemption derived from it (issue #929)
releaseBranch is optional, and deliberately so. A repository with one
long-lived branch has nothing truthful to put there, and a required field
would be satisfied by repeating defaultBranch — which validation then
refuses as a collision, leaving such a repository no valid declaration at
all. Two separate verdicts, because they are two different defects with two
different fixes:
| Rule | When |
| --- | --- |
| release-branch | releaseBranch is present and is not a valid Git branch name — the same check defaultBranch gets. |
| release-branch-collision | releaseBranch is a perfectly good branch name that happens to equal defaultBranch. A repository that deploys from its default branch has one long-lived branch: omit the field rather than repeating it. |
The field is declared on v3 only. releaseBranch on a v1 or v2 profile is
reported as unknown-field and is not additionally judged on its value —
those shapes are closed, and a legacy declaration must not be able to state a
topology its own version does not define.
This is the half that makes the branch-provenance exemption checkable. See
branchExemptionsFromProfile under Conventions: until the
topology existed as data, an exemption list and the repository it claimed to
describe could not be compared, because only one of them existed.
Requirement-id grammar (issue #316)
A requirement id is exactly two dot-separated segments, <category>.<subject>,
where category is one of REQUIREMENT_ID_CATEGORIES (runtime, tool,
dependency) and subject is lowercase words joined by hyphens — for example
runtime.node, tool.git, `tool.package-manager
