@clossys/builder
v0.11.0
Published
Declared reality made actual: an idempotent manifest engine and machine verification, a deployment surface contract with read-only platform adapters, a toolchain pin (runtime, package manager, build order), the liveStateSurface contract and shared CI gate
Maintainers
Readme
@clossys/builder
Declared reality made actual. A runtime pin, a machine manifest, and a
deployment target are the same statement at three altitudes — this is what
should exist; go and see whether it does. This package holds the manifest
engine, the deployment surface contract, the toolchain pin, the shared
liveStateSurface reconciliation contract all three of those sit on, the CI
gate mechanics that reconcile any of them for real, and an observation-bundle
transport for the fleet's own inverted evaluation model — each repository
observes its own compliance in its own CI, and a plane aggregates what got
published.
npm install @clossys/builderThis 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.
Desired-state realization rate
Independent consumer evidence shows the position's owned metric meets its
setpoint over the declared review cadence. The owned metric is
desired-state realization rate, computed by
assessDesiredStateRealizationRate(). 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 live-state subjects and independent
observations. A green run of this package's tests is not a close. An
offline declaration check is not live proof.
import { assessDesiredStateRealizationRate } from "@clossys/builder";
const report = assessDesiredStateRealizationRate(input);builder-check assessment.jsonThe command prints JSON and exits 0 for satisfied, 1 for violated, and
2 for indeterminate, unreadable, or invalid input.
Hub inventory scoping (#997)
The assessment input may optionally carry a hubInventory document —
{ "schemaVersion": 1, "repositories": [{ "id": "..." }] } — naming the
repositories the account hub inventories. It is pure caller input: this
module never reads a hub marker, an inventory file, or gh, and an absent
hubInventory changes nothing.
When a well-formed inventory is supplied, reconciliation is scoped to it:
- every declared subject must name the repository it belongs to
(
repositoryId); one without it is asubject-repository-id-requiredfinding; - a subject whose
repositoryIdthe inventory does not list is flaggedunlistedand excluded from the rate — it is the hub's reconciliation work to list a repository, not this metric's job to certify one it was never told about; and - observations tied to unlisted subjects are never counted as evidence.
A malformed inventory never scopes anything — every declared subject stays
in the denominator, and the hub-inventory-shape finding is the caller's
signal that the run was not scoped.
This package declares that command as its first-day assessment surface in its own manifest:
"foundry": { "assessment": { "bin": "builder-check", "invocation": "single-json-input" } }Onboarding discovers that declaration from the installed manifest and never
infers a surface. builder-verify-toolchain and builder-verify-machine
stay flag-shaped tooling CLIs; they are not the assessment surface. Builder
is not a required first-day role; Advisor remains the only required
first-day assessment.
The job
Builder owns the gap between what a plane declared and what the machine, the platform, or the pipeline actually is — whether the subject is a toolchain, a manifest, or a deployment target. It never guesses and it never lets a green offline check stand in for a live answer it never actually checked.
liveStateSurface (#255)
Three unrelated subjects — a manifest's destinations, a deployment target's health, a toolchain's runtime — turn out to share one structure:
- a declaration of intent, checkable offline;
- a live state owned by somewhere else;
- a reconciliation surface that may or may not exist yet;
- and a failure mode where the offline check goes green and gets mistaken for the whole answer.
The canonical implementation of this shape now lives in
@clossys/controller/conventions — it owns every rule this
repository's tiers share and has no dependency of its own, so this package
(which already depended on controller for GateResult) re-exports its copy
rather than keeping a second one. Every name below is unchanged for a
consumer of @clossys/builder: this is a consolidation of where the
code lives, not a change to what it does. See
@clossys/controller's own shipped documents,
live-state-reconciliation.md for the shared document and
routine-declaration.md / schedule-declaration.md for the two tiers that
motivated generalizing this shape in the first place.
LiveStateSurfaceDeclaration is that declaration, with every field the two
tiers that motivated this contract already used in practice:
import { validateLiveStateSurfaceDeclaration } from "@clossys/builder";
const findings = validateLiveStateSurfaceDeclaration({
store: "the GitHub Actions branch-protection API",
readableByScript: true, // explicit, never left implicit
readableBy: "policy-drift.mjs",
note: "A green run here is not evidence this is live — only policy-drift.mjs reading the real API says that.",
});readableByScript: false requires reconciledBy instead of readableBy —
what reconciles this subject when a script cannot read it at all. note is
required either way, and validateLiveStateSurfaceDeclaration rejects a note
that does not actually state the caveat: a green offline check is never
evidence the declared thing is live.
The finding-kind vocabulary, all five
import { LIVE_STATE_SURFACE_FINDING_KINDS } from "@clossys/builder";
// [
// "declared-but-not-live",
// "live-but-not-declared",
// "live-differs-from-declared",
// "live-artifact-predates-its-declaration",
// "declared-but-not-verifiable",
// ]This is a finding vocabulary, not a verdict vocabulary — all five kinds can
appear in a drifted report's findings list. The first four are outright
disagreements a completed attempt found between declared and live state. The
fifth, declared-but-not-verifiable, names one dimension of the comparison
that could not be evaluated at all (a declaredAt/liveObservedAt that
could not be parsed as an instant, for example), reported as its own finding
precisely so it cannot silently discard a real disagreement already found
alongside it. The same name is also, separately, the one declared reason an
entire reconciliation attempt reports at the outcome level when nothing
about the subject could be read at all: a reconciliation surface that cannot
currently read live state is a declared gap with a named blocker, never
a silent pass.
Three states, enforced in the types
reconcileLiveState returns exactly one of verified / drifted /
could-not-verify — never a boolean, and never a fourth state. This reuses
@clossys/controller/gates's GateResult ternary rather than
inventing a fifth shape of the same idea inside this package. The
could-not-verify constructor enforces the "named blocker" rule at
construction time, not merely by convention:
import { liveStateCouldNotVerify, reconcileLiveState } from "@clossys/builder";
liveStateCouldNotVerify("deployment.web", ""); // throws — never a silent pass
const report = reconcileLiveState<string, string>({
subject: "toolchain.runtime.node",
declared: { value: "20.11.1" },
observation: { attempted: true, live: "18.19.0" },
agrees: (declared, live) => declared === live,
});
// report.result.verdict === "violated"
// report.result.findings[0].kind === "live-differs-from-declared"Observation-bundle transport (#255, narrowed)
The fleet's evaluation model is inverted from a central scanner: each repository runs its own gates in its own CI and observes its own compliance; a plane wanting a fleet-wide picture reads what each repository already concluded about itself rather than re-scanning it centrally. Until now, those self-observations had no standard shape or transport — every plane that wanted one improvised its own.
#255 originally proposed generalizing that gap into a full
declared-intent-vs-live-state framework. The owner narrowed that on
purpose: an API without a consumer is a guess, and the reconciliation half
of that shape already shipped as liveStateSurface above. What ships here
is the transport — the one shape a repository's CI writes and a plane's CI
reads — with everything else left for a real second consumer to motivate.
What this is not:
- No network I/O anywhere in this package. Fetching a bundle from wherever a repository published it is the consuming plane's own job.
- No storage opinion. A bundle can be a committed artifact, a release asset, or anything else a caller's CI decides — the contract is the shape, not where it lives.
- No scheduling, no polling, and no further generalization of
liveStateSurface. That remains#255's open remainder, filed as its own follow-up issue rather than guessed at here.
Writing one repository's observation
import { writeObservationBundle } from "@clossys/builder";
const serialized = writeObservationBundle({
repository: { id: "example-org/example-app", ref: "a1b2c3d" },
producedAt: new Date().toISOString(), // the CALLER's clock, never this package's
gates: [
{ gateId: "secret-scan", result: { verdict: "satisfied", evaluated: 12 } },
{
gateId: "release-readiness",
result: { verdict: "violated", findings: [{ rule: "version-not-bumped", severity: "high", message: "…" }] },
},
],
});
// serialized is a JSON string -- write it wherever this repository's own CI
// decides an observation belongs.writeObservationBundle is pure: caller-supplied data in, a serialized
bundle out. It never reads a clock and never touches the network or the
filesystem — producedAt is the caller's own timestamp, supplied
explicitly, exactly like liveStateSurface's declaredAt/liveObservedAt
above. Each gate's result reuses @clossys/controller/gates's
GateResult ternary directly rather than a parallel shape — this package
already depends on controller for it throughout.
Aggregating N repositories' observations
import { aggregateObservations } from "@clossys/builder";
// `bundles` is already-fetched data -- the plane's own CI did the fetching.
// This function never fetches anything itself.
const report = aggregateObservations({
expectedRepositories: ["org/repo-a", "org/repo-b", "org/repo-c"],
bundles: [bundleA, bundleB], // org/repo-c never showed up
now: new Date().toISOString(),
staleAfterMs: 24 * 60 * 60 * 1000, // 24h -- how old one CONTRIBUTING BUNDLE may be
maxResultAgeMs: 60 * 60 * 1000, // 1h -- how old THIS RESULT may be before a later reader can no longer vouch for it
});
report.unobservedRepositories; // ["org/repo-c"] -- named, not a footnote
report.overall.verdict; // "indeterminate" -- an unobserved repository can never read as clean
report.computedAt; // echoes `now` -- carried so a persisted copy of `report` can be freshness-checked laterA repository this aggregation expected to hear from but did not, a bundle
that fails validateObservationBundleShape, a bundle older than the
caller-supplied staleAfterMs, and two or more bundles claiming the same
repository identity are all reported indeterminate, with one of four
named reasons — never omitted from report.repositories, and never
resolved by picking one and discarding the rest ("last-write-wins"):
| Reason | Meaning |
| --- | --- |
| unobserved-repository | No bundle at all was received for this expected repository. |
| invalid-bundle-schema | A bundle claiming this repository failed validateObservationBundleShape. |
| duplicate-repository-identity | Two or more bundles claim the same repository. |
| stale-observation | The bundle's producedAt is older than staleAfterMs relative to now. |
report.overall folds every report.repositories[].result with this
package's own foldGateResults (@clossys/controller/gates),
whose documented precedence — indeterminate beats violated beats satisfied
— is exactly what keeps "2 of 5 repositories were unobserved" from
silently reading as "the 3 we heard from were clean, so we're done."
report.overall.verdict is "satisfied" only when every expected
repository was cleanly, freshly, and uniquely observed and every one of its
own gates was itself satisfied.
report.receivedCount (bundles that passed schema validation, independent
of whether they matched an expected repository) and
report.unexpectedRepositories (repository ids present in bundles but
never named in expectedRepositories) are reported for the same reason:
nothing this function is handed is silently dropped from the report, even
data that turned out not to matter for the verdict.
Close condition: this loop closes for a plane aggregating per-repository
bundles when three predicates all hold: every id in expectedRepositories
has exactly one entry in report.repositories; every entry's classification
is observed (the four gap classifications — unobserved-repository,
invalid-bundle-schema, duplicate-repository-identity,
stale-observation — are each indeterminate by construction and therefore
never part of a close); every observed entry's result.verdict is
satisfied; and report.unattributedCount is zero — a bundle that arrived
but could not be tied to any repository id is evidence that was never
evaluated, and it folds into overall as unattributed-bundle
(indeterminate), so it can never coexist with a close. report.overall.verdict then reads satisfied because all
three held, never because the aggregator simply stopped looking. A
repository that never reports in must show up as a named gap, not as a
shorter list. The loop reopens on any bundle shape aggregateObservations
cannot sort into one of the four gap classifications above: that is a transport-contract
gap, filed the same way #255's narrowed-off remainder (scheduling,
polling, further generalization of liveStateSurface) already is, rather
than patched ad hoc inside this package.
The aggregate's own age is a different question from any bundle's (#340)
stale-observation above answers "is any ONE contributing bundle too old to
fold in." It cannot answer "is this AGGREGATION ITSELF too old to still be
presented as current" — a plane that only re-runs this aggregation on a push
to the repository that hosts it can go a long time between runs, and every
one of its inputs can change in the meantime with nothing re-evaluating the
aggregate to notice. aggregateObservations cannot detect that about its own
output: report.computedAt always equals the now it was just called with,
so no check inside that one call can ever observe its own result aging after
the fact.
That is why report also carries computedAt and maxResultAgeMs —
so that a caller who persists report (a committed artifact, a status
check, anything a different process reads later) can hand both back to
checkObservationAggregateFreshness, alongside a FRESH now supplied at
read time, to ask the question aggregateObservations cannot ask of its own
output:
import { checkObservationAggregateFreshness } from "@clossys/builder";
// `storedReport` was read back from wherever the previous run persisted it --
// not produced by the same call as the freshness check below.
const freshness = checkObservationAggregateFreshness({
computedAt: storedReport.computedAt,
maxResultAgeMs: storedReport.maxResultAgeMs,
now: new Date().toISOString(), // the reader's own clock, at READ time
});
// A stale but otherwise-unanimous-pass stored report must never silently
// stay satisfied: fold the stored verdict together with the freshness check
// through any GateResult fold, including this package's own dependency,
// `@clossys/controller/gates`'s `foldGateResults` -- the same
// combinator `aggregateObservations` already uses internally.
const effective = freshness.verdict === "satisfied" ? storedReport.overall : freshness;checkObservationAggregateFreshness reports indeterminate with reason
stale-aggregate-result — never a restated stale-observation, because the
two questions are different: stale-observation is about one bundle's age
at aggregation time; stale-aggregate-result is about the aggregate's own
age at whatever later moment it is actually read. A schedule (or any other
input-driven trigger) makes this staleness less likely; this check is what
makes it detectable, because it is the one thing that still works when that
trigger has silently stopped firing.
Or from the shell, once built — the same builder-verify-toolchain bin
verifyToolchain uses, via its aggregate-observations and
check-observation-freshness subcommands:
npx builder-verify-toolchain aggregate-observations --inputs ./aggregate-observations-inputs.json
npx builder-verify-toolchain check-observation-freshness --inputs ./check-observation-freshness-inputs.json
# Exit codes for both: 0 = satisfied, 1 = violated, 2 = indeterminate/could not run.Manifest engine (formerly @example/provisioning)
An idempotent engine for applying a provisioning manifest to a machine, and for checking afterwards whether the machine still agrees with it. The engine is neutral machinery: it applies manifests and owns none, and it has no opinion about what belongs in one.
import {
applyInstallation,
createNodeFileSystem,
createRuntimeContext,
loadManifest,
planInstallation,
verifyInstallation,
} from "@clossys/builder";
const manifest = loadManifest(JSON.parse(rawManifestJson));
const runtime = createRuntimeContext(manifest, { home: os.homedir(), sourceRoot: myCanonicalCheckout });
// Pure. Touches no filesystem, so it can be printed or reviewed first.
const plan = planInstallation(manifest, runtime);
const fs = createNodeFileSystem();
const result = applyInstallation(plan, fs, { backupRoot: `${runtime.home}/.config-backups/${stamp}` });
// Later, or in a drift check: reads the machine, not the manifest.
const findings = verifyInstallation(plan, fs);links, copies, managedBlocks, and privateDirectories are the four
manifest collections; expandTokens resolves ${HOME}, ${SOURCE_ROOT},
${WORKSPACE_ROOT}, and any extraTokens a caller supplies.
composeManagedBlock, renderManagedBlock, withoutManagedBlock, and
hasExactlyOneBlock operate on the marker-delimited region a managed block
owns inside a file this engine does not otherwise own.
Safety properties, unchanged from provisioning: nothing is overwritten
before it is backed up (ApplyOptions.backupRoot is required); private
directories are handled first; a links entry declared with target
(a chained link) applies last, after copies and managed blocks; a same-content
symlink at a copy destination is replaced rather than accepted; ambiguous
managed-block markers are refused, not guessed; applying throws, verifying
reports.
Multi-source composition (#240)
The engine above assumes one sourceRoot and one plan. That is unchanged
and every existing single-source caller keeps working exactly as written
above — this section covers a separate, additive layer for a caller that
needs to compose one installation plan out of several independently-owned
sources, e.g. several account-owned workspace checkouts, without staging
them under one repository authority.
Build one Plan per source with the same createRuntimeContext /
planInstallation shown above — nothing new there — then tag each with the
identifier it asked to be known by and compose them:
import {
applyComposedInstallation,
composeInstallationPlans,
createRuntimeContext,
createNodeFileSystem,
loadManifest,
planInstallation,
verifyComposedInstallation,
} from "@clossys/builder";
const namedPlans = sources.map(({ name, sourceRoot, manifest }) => ({
source: name,
plan: planInstallation(manifest, createRuntimeContext(manifest, { home, sourceRoot })),
}));
// Pure, like planInstallation. Every operation carries the source that
// requested it; throws DestinationCollisionError, naming every contributing
// source, if two sources claim the same destination.
const composed = composeInstallationPlans(namedPlans);
const fs = createNodeFileSystem();
const result = applyComposedInstallation(namedPlans, fs, { backupRoot });
const findings = verifyComposedInstallation(namedPlans, fs);
// findings[i].source names which source's plan the finding came from.composeInstallationPlans validates the full composition — including
collision detection — before applyComposedInstallation makes a single
filesystem call, so a colliding destination is never discovered halfway
through an apply; nothing from either contributing source is written.
Collision detection covers links, copies, and managedBlocks
destinations only. Two sources asking for the same privateDirectories
entry to exist is not a collision — creating (or leaving alone) a directory
with a fixed mode is idempotent and non-destructive regardless of which
source asks or how many do, the same line loadManifest already draws for
one manifest's own destinations.
applyComposedInstallation applies each source's plan through the
unchanged applyInstallation, in the order namedPlans supplies, sharing
one backupRoot. A known limit, not an oversight: a chained link
(a links entry declared with target) or a copy/managed-block destination
nested inside another source's private directory only resolves correctly if
the source it depends on was already applied — this package does not detect
or order around a cross-source dependency, because doing so would mean
inventing a precedence policy across sources, which is exactly what this
package must not do (see "What this does not do" below). Supply namedPlans
in an order that satisfies any real cross-source dependency, or avoid
creating one.
What this does not do, and why: composing several sources' plans is the only thing this module does. It never discovers what those sources are — that is a caller's job, informed by whatever registry or configuration names its account-owned workspace repositories, never this package's. It never decides which source wins a conflicting destination — a collision is always refused, never resolved, because resolving it is a precedence and policy decision this package does not make (see #240's Boundaries: no account discovery, no precedence or conflict-resolution policy, inside provisioning or its successor here).
This first increment covers three of #240's eight acceptance criteria — multiple named sources with a preserved single-source compatibility path, per-operation provenance, and pre-mutation collision detection with evidence. It deliberately does not cover the other five: a durable applied receipt (so a later run can compare against what actually got installed, not just against the current plan); retirement planning for a destination a prior plan owned that a newer plan no longer names; the clean-room adapter qualification; and account discovery itself, which is out of scope for this package by design (see "What this does not do" above), not merely undelivered. See the pull request that introduced this section for the full breakdown.
A note on vocabulary: this module does not report through
liveStateSurface's declared/verified/could-not-verify vocabulary
described above. That contract reconciles one declaration against one live
read of a subject somewhere else; a destination collision is neither —
it is two declared plans disagreeing with each other, discovered before
either is applied and before there is any live state to read at all. The
one place this module does read live state — verifyComposedInstallation
— delegates to the unchanged verifyInstallation, which already reports
through the pre-existing Finding vocabulary rather than liveStateSurface;
changing that return shape is a breaking change to already-published
behavior and is out of scope here.
Machine composition (./machine subpath, #393)
Composing one machine's skill tree from several account-owned workspace checkouts plus a third-party-scoped skill source — the mechanism a retiring account-owned installer repository used to provide, promoted into this package once that repository had nothing left to place. Two decisions were made explicitly rather than by default, and both are recorded here because "the package that already holds the files" and "the package that owns machines" were both defensible, and only one shape of composition actually worked with what this package already ships.
Decision 1: builder owns the mechanism, not controller
@clossys/controller/conventions already ships the account-neutral,
package-owned content itself — five documents plus a managed shell
block — and #393 leaves every one of those files exactly where they are,
untouched. What is missing is not content; it is a mechanism to place
content and per-account skill trees onto a machine, and roughly 90% of that
mechanism already lived in this package before #393: composeInstallationPlans,
applyInstallation, verifyInstallation, and GateResult are all builder's
own. Building the machine installer inside controller would have meant
duplicating composition.ts in a package that has no reason to depend on the
filesystem-mutation half of this one. Builder's own charter — "declared ->
actual: toolchain, pipeline shape, machines, platforms" — already names
"machines" as a first-class subject; controller's does not. So: builder
owns the mechanism, controller keeps owning the content. A caller composing
a real machine imports package-owned entries from controller's
CONVENTION_DOCUMENTS / CONVENTION_ADAPTERS catalog (@clossys/
controller/conventions) as one more source alongside the account and
third-party sources this subpath discovers — this package does not fold that
catalog into a manifest itself, because doing so would mean hard-coding one
particular machine's destination paths into a public, account-neutral
package. That composition — which document goes to which absolute path on
which real machine — is local machine state, never checked in here.
Decision 2: composition is union by directory link, not per-skill links or a copy
~/.agents/skills (or wherever a caller composes skills to) must present the
union of several accounts' skill trees plus a third-party tree. This decision
was made twice. The first pass (documented in this package's history) adopted
per-skill links; the owner overturned that on 2026-09-21 (#393) in favor
of union by directory link, recorded below. Three shapes were on the table:
A single directory symlink — the mechanism this replaces used. Rejected: a symlink can only point at one source, so it cannot union N accounts' trees no matter how many are discovered.
A materialized copy of every account's skills into one directory. Rejected: a copy drifts from its source the moment the source changes, and this package's own manifest engine already treats "canonical elsewhere" as link-not-copy everywhere else it applies (see
linksvs.copiesin../types.ts) — inventing a copy-based exception here for skills alone would be a second policy this package does not need.Union by directory link. Adopted. Each discovered source (
./discovery.ts's account workspaces,./third-party.ts's vendored skills) becomes one namedPlancarrying exactly ONElinksentry — the whole source tree, linked as a single directory into<composedSkillsRoot>/<sourceName>(./skills-manifest.ts'sbuildSkillsManifest) — never one entry per skill.sourceNameis the same identifier the source already composes under (an account's declaredaccount, or the literal"third-party"), socomposedSkillsRoot's immediate children are always attributable to exactly one source at a glance.This was NOT the first shape tried, and the reversal has a real cost worth naming: per-skill links let
composeInstallationPlans's EXISTING per-destination collision check catch a same-named skill across two sources as a side effect of link creation, for free. Directory-linking gives up that side effect — two sources' directory links never share a literal destination, by design, so the built-in check no longer sees a same-named-skill collision at all../skills-manifest.ts'sdetectSkillNameCollisionscloses that gap explicitly: it enumerates the skill names in every source tree and compares them BEFORE any link is planned, returning../composition.ts's ownDestinationCollisionshape — never a second collision vocabulary — soreport.tsfolds a skill-name collision into a report exactly the way it already folds a realDestinationCollisionErrorfromcomposeInstallationPlansitself (a class-one destination colliding with an account's directory link, for instance — see the "destination collisions" test coverage forverifyMachine, which does not ship). Two accounts shipping a skill with the same name is still a reported conflict, never a silent last-writer-wins; it is just no longer free.
Class 1: package-owned, account-neutral conventions, and where its destination map lives (#410)
#393 named three classes of source; the two above are classes 2 and 3.
Class 1 — machine guidance, agent policy rules, shell integration, command
hooks — is not new content. It already ships in
@clossys/controller/conventions's CONVENTION_DOCUMENTS and
CONVENTION_ADAPTERS catalogs, and the retiring account repository was only
ever the placement mechanism for it. What was missing was not the content; it
was where the mapping from that content to real machine destinations
lives, and #410 exists because three of the four candidate homes fail:
- Inside
builderitself — every consumer of a general-purpose package would inherit one particular machine's opinion about where its own files go. A composition engine is general; a specific machine's destination map is not. - Inside a surviving account plane — this breaks the rule the whole design rests on: no account plane may reach into another's, and only the machine layer sees all of them. Declaring the machine's destination map from inside one account plane is reaching, by definition.
- Inside the retiring repository — the status quo being retired, listed only to be ruled out explicitly.
- On the machine, in no repository at all. A machine-local declaration, owned by the machine layer, versioned nowhere because it is not a shared artifact. This is the one adopted.
./machine-layer.ts's MachineLayerDeclaration is that declaration: a plain
JSON document, read from a path the caller supplies or from
BUILDER_MACHINE_LAYER_DECLARATION_PATH, naming — for each catalog id it
places — an install kind (link, copy, or managed-block) and a
destination expressed relative to home, never absolute. home is
already an explicit input verifyMachine never infers
(MachineVerifyInputs.home), so there was never a real "absolute paths baked
into a public package" problem to solve by keeping this out of builder —
that reasoning, floated early in #393's history, was the wrong one and #410
explicitly retires it. The actual reason class 1's map stays out of this
package is ownership: a general engine should not ship one machine's opinion.
Because the declaration is a plain local file — never a path inside a
repository, never fetched over the network — reading it needs no credential
of any kind, for any repository. That is a stronger property than
packages/observer's coverage-declaration.ts contract achieves for its own
different problem (a repository-hosted declaration reachable by an
unauthenticated raw-content GET): there is no repository here to need
unauthenticated access to in the first place.
buildClassOneManifest is catalog-aware and throws on an id the catalog does
not ship, or on templated content (a document or adapter carrying
${TOKEN} placeholders) declared "link" — a symlinked reader would receive
the literal token. loadClassOnePolicy is the public entry point and never
throws, following packages/integrator's detectSupersession split exactly:
every internal failure folds into a named indeterminate reason, including
an empty destinations array — nothing declared is not evidence class 1 has
been composed (#338).
verifyMachine's class-one source is optional the same way third-party is:
omitting classOneDeclarationPath and the environment variable means "this
run does not compose class 1," not a failure — but composed, once supplied,
it goes through the exact same composeInstallationPlans classes 2 and 3
already use, tagged "package-conventions", so a destination it shares with
an account workspace or a third-party skill is a reported
DestinationCollisionError like any other, never a silent last-writer-wins.
The single-directory-symlink to per-source-directory-links transition (#240)
On the machine this replaces, ~/.agents/skills (composedSkillsRoot) is
today a single directory symlink into the repository being retired. This
subpath's own composed shape is one directory link per source (decision 2,
above) — many links, each scoped under a real, non-symlinked
composedSkillsRoot, rather than one symlink standing in for the whole
union. #240's own reproduction records the naive transition crashing
applyInstallation on a stale dangling link: the old symlink no longer
resolves, and the generic engine's own recursive mkdir (called while
preparing to write the first source's directory link) throws an opaque,
unrelated ENOENT deep inside apply.ts's replace() — never a clear,
actionable finding. The non-dangling case is just as unsafe in the other
direction: a symlink still pointing at a directory that still exists lets
mkdir walk straight through it, silently writing the new directory link
INSIDE the old repository's tree instead of into a real, machine-owned
directory.
./skills-manifest.ts's buildSkillsManifest closes both cases by declaring
composedSkillsRoot itself as a privateDirectories entry (create: true)
on every source's manifest, ahead of that source's own single directory
links entry — no new engine mechanism, reusing the existing safety
apply.ts's applyPrivateDirectory and verify.ts's verifyPrivateDirectory
already enforce for every other private directory this engine manages: a
destination that is a symlink (dangling or not) is refused with a named,
actionable error before anything is touched, and multiple sources declaring
the identical private directory is not a collision — composeInstallationPlans
already exempts private-directory operations from collision detection (see
"Multi-source composition," above), which is exactly the shape needed here
since every account, third-party, and class-one source shares the one
composed root. This refusal stays scoped to composedSkillsRoot ITSELF —
never to its children, where a directory symlink per source
(composedSkillsRoot/<sourceName>) is now the entire, intended output.
./skills-manifest.test.ts reproduces the exact transition #240 recorded —
a dangling directory symlink at composedSkillsRoot, replaced by per-source
directory links — and asserts it fails with a named error, leaves the stale
symlink completely untouched, and never crashes.
Explicit retirement of a dropped destination (#240)
When a source stops contributing a destination it used to manage — a
workspace disappears entirely because its repository was deleted, the
third-party root is unconfigured, a class-one convention id is dropped from
the machine layer declaration — that destination must be retired explicitly,
never left silently orphaned on the machine. Under union-by-directory-link
(decision 2, above), an account workspace losing an individual skill is NOT
one of these triggers: the account's own destination
(composedSkillsRoot/<account>) is the whole source tree's directory link,
unaffected by which skills currently live inside it, so retirement here is a
statement about SOURCES disappearing, never about individual skills.
../composition.ts's
diffRetiredDestinations is the pure comparison this needs: given the
destinations a PRIOR run's composition managed and the CURRENT run's actual
composed operations, it returns a RetirementReport naming every destination
(RetiredDestination, one entry per destination-path/source/kind triple) the
prior run owned that no current source claims at all. private-directory
operations are excluded from the comparison in both directions, the
identical reasoning composeInstallationPlans already applies to its own
collision check (a directory several sources want to exist is not "owned" by
any one of them).
This never deletes anything — reporting is the entire contract, and a caller
wanting destructive cleanup must build that on top, explicitly, as its own
opt-in step; this module will never do it by default, the same discipline
applyInstallation already keeps for a destination a manifest simply stops
mentioning. verifyMachine wires this in as an entirely optional row:
supplying MachineVerifyInputs.previousCompositionPath (a JSON document
shaped {"schemaVersion":1,"operations":[...]}, typically a prior run's own
composeInstallationPlans(...).operations persisted verbatim — this module
never writes that file itself) reports every retired destination as a
machine/destination-retired finding; omitting it means "this run does not
check for retired destinations," not an error. A retirement check against
composition that itself did not resolve is reported indeterminate rather
than diffed against a partial machine.
Discovery: never a hard-coded list, never a silent partial machine
./discovery.ts's discoverAccountWorkspaces takes a root from the caller or
the BUILDER_MACHINE_WORKSPACES_ROOT environment variable — never a guessed
default, so no absolute path to any account repository appears anywhere in
this package's content. A directory under that root is a candidate only once
it has placed a builder-machine-workspace.json marker at its own root,
declaring its account identifier and its skillsPath — the same
discovery-by-self-declaration shape npm's own workspace globbing uses. A
directory with no marker is simply not a workspace, excluded the same way an
npm workspace glob excludes a directory with no package.json. A directory
with a marker has declared intent to be counted, and from that point
every failure — an unreadable marker, malformed JSON, a schema mismatch, an
unreadable declared skill tree, two workspaces claiming the same account — is
indeterminate, always present in the result, never dropped. ./third-party.ts's
loadThirdPartySkills is the third-party-scoped mirror of the same
ternary, keyed off SkillScope's own "third-party" value
(@clossys/controller/conventions) rather than a second vocabulary —
THIRD_PARTY_SCOPE's assignment stops compiling if that union ever drops the
value.
./report.ts's verifyMachine (behind builder-verify-machine, ./cli.ts +
./bin.ts — a SEPARATE compiled entry file from ../ci/bin.js, since this
repository invokes gates by direct dist path and a CLI that dispatches on the
invoked bin name is unreachable that way) is the orchestrator that makes the
whole-machine rule concrete: if ANY discovered source is indeterminate,
composition itself is skipped and reported indeterminate — never a
partially-composed machine reported as satisfied because the sources that DID
resolve happened to verify clean.
import { verifyMachine, createNodeDiscoveryPort } from "@clossys/builder/machine";
import { createNodeFileSystem } from "@clossys/builder";
const report = verifyMachine(createNodeDiscoveryPort(), createNodeFileSystem(), {
schemaVersion: 1,
home: process.env.HOME,
composedSkillsRoot: `${process.env.HOME}/.agents/skills`,
// accountWorkspacesRoot / thirdPartySkillsRoot / classOneDeclarationPath may
// come from here instead of BUILDER_MACHINE_WORKSPACES_ROOT /
// BUILDER_MACHINE_THIRD_PARTY_SKILLS_ROOT / BUILDER_MACHINE_LAYER_DECLARATION_PATH.
});
// report.overall.verdict: "satisfied" | "violated" | "indeterminate"
// report.exitCode: 0 | 1 | 2 — the same fold as everywhere else in this packagenpx builder-verify-machine --inputs machine-verify-inputs.json
# Exit codes: 0 = every managed skill destination resolves as composed,
# 1 = at least one destination or source disagreed,
# 2 = could not verify (an unresolvable source, or bad input).Deployment (./deployment subpath, formerly @example/deployment)
Dependency-free deployment surface contracts, configuration planning, health evaluation, and read-only Vercel and Render adapters — preserved as a subpath rather than flattened into the root entrypoint, so its own export shape (a root plus two provider subpaths) stays intact.
import {
defineDeploymentManifest,
evaluateDeploymentHealth,
validateDeploymentManifest,
} from "@clossys/builder/deployment";
import { createVercelInspector, renderVercelConfiguration } from "@clossys/builder/deployment/vercel";
import { createRenderInspector, renderRenderConfiguration } from "@clossys/builder/deployment/render";
const manifest = defineDeploymentManifest({
schemaVersion: "1",
surfaces: [{ id: "web", provider: "vercel", environment: "production", health: { kind: "http", url: "https://example.com/health" } }],
});
if (validateDeploymentManifest(manifest).length > 0) throw new Error("Invalid manifest");
const health = evaluateDeploymentHealth([{ surfaceId: "web", status: "healthy" }]);The provider adapters make GET requests only, obtain a bearer token from a
caller-injected provider at inspection time, and never read a secret store or
process environment. See the module doc comments under src/deployment/
for the full contract each subpath ships — the shape is unchanged from
@example/deployment's own README, which this package's history
carries forward.
Which branch feeds which environment (issue #929)
DEPLOYMENT_ENVIRONMENTS enumerates the environments and stops. A repository
that takes day-to-day work on one long-lived branch and deploys from a second
one is describing a real, checkable arrangement, and until now no part of
this contract could hold it: a surface said where it is live and how to
tell, never what feeds it.
import {
checkDeploymentBranchBindings,
defineDeploymentBranchBindings,
validateDeploymentBranchBindings,
} from "@clossys/builder/deployment";
const branchBindings = defineDeploymentBranchBindings([
{ environment: "production", branch: "release" },
{ environment: "preview", branch: "main" },
]);
const result = checkDeploymentBranchBindings({ manifest, branchBindings });
// => { ok: true, findings: [], surfacesChecked: 2, bindingsChecked: 2, productionBranch: "release" }branch is a plain string. It names a branch that a repository's own
profile — a different package's contract entirely — declares, and this
package does not import that package to resolve it. That is the same seam
@clossys/strategist's BrandDerivation already uses for token slots and
voice rules, and for the same reason: one package declares the topology, this
one declares which environment consumes it, and the repository that depends
on both is the one place the two names can actually be compared.
This stays a contract a repository satisfies. Nothing here deploys, mutates a provider, or makes a network call; the provider adapters remain read-only.
checkDeploymentBranchBindings fails closed. An empty binding list or a
manifest with no surfaces is a finding, never a vacuous pass, and
surfacesChecked/bindingsChecked are present in every result so "nothing
was compared" and "everything agreed" can never be told apart by a caller who
only glances at ok. productionBranch appears only on a clean result.
Provider-verified deploy record (issues #1518, #360)
A deployment surface can declare production branch, preview branches, the
standard preview URL, protection mode (sso or none), and required
environment names per target (preview or production), each marked
secret or plain and project or shared. Optional releaseRef: true
names the release-ref model: production is the release branch and main
is a preview branch.
verifyDeployRecord(declared, observed) compares that declaration to a
caller-supplied provider observation (fixture or read-only adapter output).
The observation carries names and settings only — never secret values — and
must also name productionCommit, builtCommit, and publicCommit as
40-hex SHAs. The verifier is satisfied only when all three are present,
equal, and the rest of the declaration matches; it reports
deploy-observation-incomplete when any commit field is missing or invalid,
deploy-provider-build-mismatch when the provider build SHA differs from
production, and deploy-public-cache-stale when the public page serves a
different SHA. It reports indeterminate when an observation row includes a
value field (including on commit fields).
planProductionRefUpdate({ productionIsAncestor }) returns the required
production-ref advance (fast-forward or a single merge commit with
production as first parent and integration as second). No git I/O.
verifyProductionRefUpdate(planned, observed) rejects force and
admin-bypass updates and reconciles fast-forward and merge observations
against that plan.
import { verifyDeployRecord } from "@clossys/builder/deployment";
const result = verifyDeployRecord(declared, observed);
// => { verdict: "satisfied", evaluated: n } | { verdict: "violated", findings: [...] }
// | { verdict: "indeterminate", reason: "observation-carries-secret-value", ... }| Export | Kind | Purpose |
| --- | --- | --- |
| verifyDeployRecord(declared, observed) | function | Reconciles declared deploy settings and production/built/public commit SHAs against a provider observation; fails on branch, preview URL, environment, protection, build, and cache drift. |
| planProductionRefUpdate(input) / verifyProductionRefUpdate(planned, observed) | functions | Plan and verify fast-forward or merge production-ref updates without git I/O. |
| DEPLOY_RECORD_INDETERMINATE_REASONS / PRODUCTION_REF_UPDATE_INDETERMINATE_REASONS | constants | Declared indeterminate vocabulary for deploy-record and production-ref verification. |
| DeployRecordDefinition / DeployRecordObservation / related types | types | Declared and observed deploy-record shapes (names and settings only). |
| Export | Kind | Purpose |
| --- | --- | --- |
| validateDeploymentBranchBindings(value) / isValidDeploymentBranchBindings(value) | functions | Strict structural validation of untyped authoring input; a throwing accessor reads as unreadable input, never as a pass. |
| defineDeploymentBranchBindings(definitions) | function | Produces a detached, explicit binding list. |
| checkDeploymentBranchBindings(input) | function | Compares a manifest's declared environments against the bindings; reports every unbound environment and an unbound production. |
| REQUIRED_BOUND_DEPLOYMENT_ENVIRONMENT | constant | The one environment a repository cannot leave unbound once a surface declares it. |
| DeploymentBranchBinding / DeploymentBranchBindingDefinition / DeploymentBranchBindingCheck / DeploymentBranchBindingFindingRule | types | The binding, its authoring shape, the check result, and the closed finding vocabulary. |
evaluateDeploymentHealth is also reachable from the shell, via the same
builder-verify-toolchain bin's deployment-health subcommand:
npx builder-verify-toolchain deployment-health --inputs ./deployment-health-inputs.json
# --inputs is { "observations": [{ "surfaceId": "web", "status": "healthy" }, ...] }.
# Exit codes: 0 = every recognized observation is healthy, 1 = at least one is
# degraded or unhealthy, 2 = could not evaluate (bad input, or no
# observation could be recognized at all).Web deployment surface: domain, DNS, and hosting (./deployment/domain subpath, #1211)
The web surface a product actually serves — one domain, its DNS records, one
hosting project, and the environment-to-branch mapping that feeds it — is
declared once and checked against live reality. Provider-neutral, with
Cloudflare (DNS) and Vercel (hosting) as the first worked example; see
vercel-hosting.ts's own header for the one place that adapter is named.
This follows #1187's governing "who does what" split exactly: this subpath
defines and verifies. It never deploys, never calls a provider's write
API, and never reads or stores a token — DNS resolution, the TLS handshake,
and an HTTP request are all public, credential-free operations by
construction. A "right deployment serving" check needs a provider credential
(Vercel's own API), so that one dimension is never performed by this
subpath directly: a caller runs createVercelInspector (./deployment/vercel)
with its own injected bearer token, and folds the result in through
observeVercelHosting.
import {
createNodeDnsResolver,
createNodeTlsProbe,
defineWebSurfaceDeclaration,
renderWebSurfaceSetupSteps,
verifyWebSurfaceLiveState,
} from "@clossys/builder/deployment/domain";
const declaration = defineWebSurfaceDeclaration({
schemaVersion: "1",
domain: "example.com",
dnsProvider: "cloudflare",
records: [
{ type: "A", name: "@", value: "192.0.2.1" },
{ type: "CNAME", name: "www", value: "example.com", proxied: true },
],
hostingProvider: "vercel",
hostingProject: "example-site",
build: { command: "npm run build", outputDirectory: ".next", applicationRoot: "apps/site" },
environments: [{ environment: "production", hostname: "example.com", branch: "main" }],
routes: ["/about"],
});
// The exact configuration a coding agent reviews and applies through the
// provider CLIs, with the human's approval. Deterministic; no I/O.
for (const step of renderWebSurfaceSetupSteps(declaration)) console.log(step);
// Live verification: read-only, and needs no provider token for DNS, TLS, or HTTP.
void (async () => {
const report = await verifyWebSurfaceLiveState(declaration, {
resolveDns: createNodeDnsResolver(),
probeTls: createNodeTlsProbe(),
fetch,
});
console.log(report.overall.verdict); // "satisfied" | "violated" | "indeterminate"
})();Every one of the four checks is independent and every one degrades to
indeterminate — never satisfied — when it could not reach what it was
checking (offline, DNS timeout, an unreachable host): see #914.
report.overall is satisfied only when DNS, TLS, HTTP, and a supplied
hosting observation all are; omitting hostingObservation reports hosting
(and therefore overall) indeterminate with reason "hosting-not-configured"
rather than silently dropping that dimension from the result.
A proxied DNS record (Cloudflare's orange-cloud, or any provider that
terminates traffic at its own edge) is checked only for resolving at all —
its live value is the provider's own edge address, never the declared
origin, so an exact match is never asserted for one.
| Export | Kind | Purpose |
| --- | --- | --- |
| defineWebSurfaceDeclaration(definition) / validateWebSurfaceDeclaration(value) / isValidWebSurfaceDeclaration(value) | functions | Structural validation and a detached, defaulted declaration — the same define/validate split the rest of ./deployment already holds. |
| normalizeWebSurfaceDeclaration(declaration) / serializeWebSurfaceDeclaration(declaration) | functions | A stable, diffable sort order and its JSON serialization. |
| renderWebSurfaceSetupSteps(declaration) | function | The exact, deterministic configuration steps a coding agent reviews and applies. Zero I/O. |
| checkDnsRecords(declaration, ports) / checkTlsCertificate(hostnames, ports) / checkRoutes(hostname, routes, ports) | functions | The three credential-free live checks, each returning a GateResult (@clossys/controller/gates). |
| createNodeDnsResolver() / createNodeTlsProbe() | functions | The real node:dns / node:tls adapters a caller wires into the checks above. |
| observeVercelHosting(inspection, input) | function | Folds a createVercelInspector(...).inspect(...) result into the "right deployment serving" dimension. Pure; touches no token. |
| verifyWebSurfaceLiveState(declaration, ports) | function | The aggregate report — dns, tls, http, hosting, and overall. |
| DnsRecord / DnsRecordType / DNS_RECORD_TYPES / WebSurfaceDeclaration / WebSurfaceEnvironment / WebSurfaceBuildSettings | types | The provider-neutral declaration schema. |
| WebSurfaceLiveVerificationReport / WebSurfaceVerificationPorts | types | The live-check aggregate and the ports it takes. |
Hosting commands and local dev ports (#1526, #1529)
A surface's hosting config calls two package commands. The Vercel adapter names them:
import { vercelHostingCommands } from "@clossys/builder/deployment/vercel";
vercelHostingCommands("web");
// installCommand: npx @clossys/builder@<version> hosting install --surface web
// ignoreCommand: npx @clossys/builder@<version> hosting should-build --surface webrenderVercelConfiguration writes those commands into vercel.json as
installCommand and ignoreCommand.
builder hosting install --surface <id> reads builder.hosting.json. For
each private scope it checks that the repository route file sends that scope
to the declared registry, then runs npm ci. A rejected credential, a
missing credential, and a mis-routed scope are different errors, and each
names the scope. The command's output does not include the credential. The
credential is written to a user-level config outside the repository and that
file is removed when the command exits. The install's child environment does
not receive the credential value or VERCEL_TOKEN.
The build environment is checked by name. A declared name is present or
absent. VERCEL_TOKEN is not placed in that build environment.
{
"schemaVersion": "1",
"surfaces": [{
"id": "web",
"inputs": ["apps/web"],
"privateScopes": [{
"scope": "@example",
"registry": "https://npm.example.test",
"credentialVariable": "EXAMPLE_REGISTRY_TOKEN"
}],
"buildEnvironment": ["EMAIL_PROVIDER_KEY"]
}]
}The route file line for that scope is
@example:registry=https://npm.example.test.
builder hosting should-build --surface <id> decides from the surface's
declared inputs plus the files the hosting commands reference, including the
install command's own file. When that file is the only change, the command
prints changed: src/hosting/install.ts and exits 1. Exit 0 means no
relevant input changed. Exit 1 is the exit Vercel's ignore command treats as
"continue the build."
A deployment surface record can declare localPort. The dev script is
checked against that port: it must pass the same port with --port or -p.
import { defineDeploymentManifest, validateDeploymentManifest } from "@clossys/builder/deployment";
const manifest = defineDeploymentManifest({
schemaVersion: "1",
surfaces: [{
id: "web",
provider: "vercel",
environment: "development",
localPort: 4321,
devScript: "next dev --port 4321",
health: { kind: "http", url: "https://example.test/health" },
}],
});
validateDeploymentManifest(manifest);builder hosting install --surface web
builder hosting should-build --surface webToolchain
The runtime pin, the package-manager pin, and the build order, expressed and
reconciled as the same liveStateSurface statement everything else in this
package makes. A pin is an exact value — never a range; see src/toolchain.ts
for why guessing at range syntax this module does not parse would be worse
than refusing to.
import { reconcileToolchain, validateToolchainDeclaration } from "@clossys/builder";
const declaration = {
runtime: { name: "node", version: "20.11.1" },
packageManager: { name: "npm", version: "10.5.0" },
buildOrder: { packages: ["core", "adapters", "app"] },
};
if (validateToolchainDeclaration(declaration).length > 0) throw new Error("Invalid toolchain declaration");
const [runtime, packageManager, buildOrder] = reconcileToolchain(declaration, {
runtime: { attempted: true, live: process.version },
packageManager: { attempted: true, live: "10.5.0" },
buildOrder: { attempted: true, live: declaration.buildOrder.packages },
});This package never reads process.version, spawns a shell, or reads a root
manifest itself — every observation is supplied by the caller. A function
that read its own runtime could never be tested for the disagreeing case
without actually running on a disagreeing machine.
Shared CI gate mechanics (#257)
The same secret-scan-shaped CI gate had been built independently at least
four times across this account family with no channel between the copies —
a fix discovered in one copy had no way to reach the other three. #257 asks
for that mechanism to live in one place, shipped as importable machinery a
consumer's own thin workflow invokes — the shape
@clossys/inspector now provides — rather than as a
cross-repository reusable workflow reference. A uses: pointing at another
account's workflow or action is not the sanctioned exception this account
family's cross-account boundary rule allows; a downstream package depending
on an upstream package is, and that is what this subpath ships.
./ci holds the shared half — a fold from any number of LiveStateSubjectReport
values into one 0/1/2 exit-code verdict (foldLiveStateReports), plus a
minimum-safe-version staleness floor (checkVersionFloor) that gives a caller
running a pre-fix build the same "you are behind" signal
@clossys/inspector provides, not merely a changelog
entry someone may or may not read — and the concrete gate built on both:
builder-verify-toolchain, an installed CLI a consuming repository's own
thin GitHub Actions workflow runs. See
documents/caller-workflow.md for that
workflow in full, including why the decisive command must never sit on the
left side of a pipe under GitHub Actions' bash -e {0} (pipefail unset)
and why "can't run here" must become an exit 2, never an if: skip — a
skipped required check reports to a merge gate as satisfied, not absent.
npx builder-verify-toolchain --inputs verify-toolchain-inputs.json
# Exit codes: 0 = verified, 1 = drifted, 2 = could not verify.The same fold and floor are also importable directly, for a future gate over a different subject that wants this package's mechanics without shelling out to the installed binary:
import { checkVersionFloor, foldLiveStateReports } from "@clossys/builder/ci";Every check here is hermetic: src/ci/*.test.ts injects every declaration,
observation, and file read, and calls no network and no real machine.
Metric
Drift: the count of subjects where declared and actual disagree, plus, reported separately, the count that could not be verified at all. Neither is folded into the other — a build that reports zero drift by refusing to look at half its subjects has not reported zero drift.
Loop
- aim — every declared subject (a manifest destination, a deployment surface, a toolchain pin) exists as declared.
- sense — read the live state, not the declaration.
- judge —
verified/drifted/could-not-verify. - act — apply idempotently where the plane granted authority
(
applyInstallation); otherwise report the gap with its blocker named (liveStateCouldNotVerify). - learn — a subject that is repeatedly
declared-but-not-verifiableis a missing credential or a missing API, filed as a named absence rather than worked around inside this package.
The loop closes when a plane's own dashboard shows zero drifted subjects
and zero could-not-verify subjects for a given run — not when it shows
zero drifted alone, which is exactly the "nobody looked" result this
package's whole design exists to keep from reading as "looks fine."
API
| Export | Kind | Description |
| --- | --- | --- |
| loadManifest(raw) | function | Validates a parsed provisioning manifest and returns it normalized |
| createRuntimeContext(manifest, options) | function | Resolves the home directory, source root, workspace root, and token table |
| planInstallation(manifest, runtime) | function | Resolves every manifest entry to absolute paths with no filesystem access |
| expandTokens(value, tokens) | function | Expands ${TOKEN} placeholders; throws on an unknown token |
| applyInstallation(plan, fs, options) | function | Applies a plan through the injected port, backing up every replaced destination |
| verifyInstallation(plan, fs) | function | Reads the machine and returns Finding[] |
| createNodeFileSystem() | function | The default FileSystemPort, backed by node:fs |
| composeInstallationPlans(namedPlans) | function | Composes several named sources' plans into one, provenance-tagged; throws DestinationCollisionError on a shared destination |
| applyComposedInstallation(namedPlans, fs, options) | function | Validates composition, then applies each source's plan through applyInstallation |
| verifyComposedInstallation(namedPlans, fs) | function | Validates composition, then returns ComposedFinding[], each tagged with its source |
| DestinationCollisionError | class | Thrown when two or more sources claim the same destination; carries collisions |
| renderManagedBlock(body, start, end) | function | The marker-delimited block text |
| withoutManagedBlock(contents, start, end) | function | Content with the managed region removed |
| composeManagedBlock(existing, body, start, end, legacyBody?) | function | The exact content a destination should hold |
| hasExactlyOneBlock(contents, body, start, end) | function | Whether a destination holds exactly one well-formed copy of a block |
| PRIVATE_DIRECTORY_MODE | number | 0o700 |
| LIVE_STATE_SURFACE_FINDING_KINDS | constant | All five finding kinds, frozen |
| validateLiveStateSurfaceDeclaration(declaration) | function | Validates one LiveStateSurfaceDeclaration's own shape |
| reconcileLiveState(input) | function | Reconciles one subject's declaration against one observation; returns a LiveStateSubjectReport |
| liveStateVerified(subject) / liveStateDrifted(subject, findings) / liveStateCouldNotVerify(subject, blocker) | function | The only three constructors of a LiveStateSubjectReport |
| liveStateReconciliationReasons | constant | The declared-but-not-verifiable reason vocabulary, scoped via createGateReasons |
| validateRuntimePin(pin) / validatePackageManagerPin(pin) / validateBuildOrderPin(pin) | function | Validate one toolchain pin's own shape |
| validateToolchainDeclaration(declaration) | function | Validates all three toolchain pins at once |
| reconcileToolchain(declaration, observation) | function | Reconciles a declared toolchain against one machine observation; returns three LiveStateSubjectReports |
| Finding / Severity | type | { rule, severity, message } and "high" \| "medium" \| "low" |
| Manifest / LinkEntry / CopyEntry / ManagedBlockEntry / PrivateDirectoryEntry | type | The four manifest collections and their entries |
| RuntimeContext / RuntimeOptions / Plan / PlanOperation / OperationKind | type | Runtime resolution and planning shapes |
| FileSystemPort / FileStats | type | The injected filesystem interface |
| ApplyOptions / ApplyResult | type | { backupRoot } and { changed, unchanged, backupRoot } |
| NamedSourcePlan | type | { source, plan } — one source's already-computed Plan, tagged with its identifier |
| ComposedPlan / ComposedPlanOperation | type | The composed plan and its provenance-tagged operations |
| ComposedFinding | type | A Finding with the source whose plan produced it attached |
| DestinationCollision | type | { destinationPath, sources } — the evidence behind a DestinationCollisionError |
| LiveStateSurfaceDeclaration | type | The store / readableByScript / readableBy / reconciledBy / note declaration |
| LiveStateSurfaceFindingKind / LiveStateDriftKind | type | All five kinds, and the four a completed attempt can report |
| LiveStateFinding / LiveStateSubjectReport / LiveStateReconciliationResult / LiveStateReconciliationReason | type | One finding, one subject's report, and the underlying GateResult shapes |
| LiveStateObservation / LiveStateDeclarationValue / ReconcileLiveStateInput | type |
