sigildocs

(sigil deps coherence)

(sigil deps coherence) - Build-time toolchain/lock coherence

Answers ONE question before a build spends any time: is the compiler that is about to emit bytecode compatible with the runtime this project has locked and installed?

The estate's signature defect is that the answer can be "no" while the build prints "Build complete!" and exits 0. That has now shipped a dead artifact to a user's phone (t-979187) and blocked three repos in a day. A dead artifact costs more than a refused build, so this REFUSES — exit 78 / EX_CONFIG, because retrying unchanged cannot succeed.

FOUR AXES, FOUR DIFFERENT DATA SOURCES. That separation is the whole design: an axis that reads the same bytes as its neighbour cannot fail independently of it, and four checks that agree by construction are one check wearing four hats.

axis reads fires when


toolchain- running compiler's identity the compiler was identity + the LOCKFILE TEXT upgraded and the lock never relocked

lock-vs-disk the LOCKFILE TEXT the lock is current

  • the .sigil/deps SYMLINK TARGETS and the deps on disk are stale

runtime-abi running compiler's identity what is ACTUALLY on

  • the HEADERS in the resolved disk cannot host sigil-lib ON DISK this compiler's output

sigil-floor the RESOLVED sigil version an installed dep

  • each installed dep's DECLARED declares it needs a sigil: floor NEWER sigil (t-f45e82)

declaration- this project's sigil: range a dep's declared range era + each installed dep's range names a different era ADVISORY - never blocks a build (maintenance debt)

runtime-abi deliberately does not read the lockfile at all. Hand-editing sigil.lock to silence the first two axes does not get past it, which is the mechanical answer to "a hand-edited lock is a lock no resolver would generate" (topics/stdlib-absorption-creates-duplicate-module-providers).

WHAT THIS DOES NOT CLAIM. A commit-identity mismatch is not proof of a dead artifact — apiary runs happily one patch release behind its compiler. It is proof that nobody has checked, over a seam where three separate incidents have already come from not checking. The diagnostic says which of the two it found, and never dresses the weaker one up as the stronger.

No exceptions are used to signal a refusal. Release binaries can answer a raise with a dead handler and convert it into a value (see investigations/sigil-build-task-errors-swallowed-in-release-binaries), so the caller reads a returned list and calls plain exit.

Exports

The packages whose identity must agree with the running compiler. sigil-lib is the C runtime and owns the ABI and bytecode headers; sigil-stdlib is the Scheme half. Both are resolved out of the codeberg:sigil/sigil monorepo, so their lock sha: is a commit in the same repository the compiler was built from — which is what makes (sigil-commit) and the lock sha directly comparable IDENTITIES rather than two version strings that happen to look alike (topics/kiln-version-string-is-not-a-binary-identity).

Minimum overlap before two abbreviated shas may be compared. Git's own short form is 7. Below this the comparison is not made at all — it returns 'unknown rather than guessing in either direction, because both wrong answers are expensive: a false 'agree passes an incoherent build, and a false 'disagree refuses a healthy one.

SIGIL_BUILD_COHERENCE = report | enforce | off.

The name says what the variable GOVERNS, not what it switches off. A variable called SIGIL_ALLOW_INCOHERENT_BUILD (the earlier design) reads as permission to do something reckless, which is precisely the thing people paste into a CI file and forget.

report findings are printed as a WARNING and the build proceeds. THE DEFAULT, deliberately, and the reason is the whole rollout: eleven of the estate's twelve consumer repos are incoherent today, and kiln — the worst of them, 22 of 23 packages skewed — CANNOT be relocked at all while t-855973's hold stands. Shipping a refusal into that means eleven teams discover on day one that the only way to build is to switch the check off. A refusal nobody can act on gets bypassed, and a bypass that becomes habit is worse than no check: it trains people to reach past the next one too. So the check reports first, the relock campaign works from what it reports, and the default flips to enforce when the estate can pass it.

enforce findings refuse the build with exit 78. This is the mode the gate exercises, and the mode this becomes by default.

off the check does not run. For someone who genuinely needs silence; it is not the default because a silent default would leave the relock campaign with no work-list and nobody opting in blind to produce one.

TO FLIP THE DEFAULT, change default-coherence-mode below. The gate asserts BOTH paths on the same tree, so flip day is not the first time anyone runs the other one.

The one line to change on flip day.

Resolve the mode. Returns (mode . note-or-#f) — the note is a string the caller must print when the variable held something unrecognised.

AN UNRECOGNISED VALUE FALLS BACK TO THE DEFAULT AND SAYS SO, LOUDLY. The earlier bypass had exactly this bug in miniature: a value that was not the magic string was silently treated as "not set", so someone who typed =true believed they were covered and was not. Two properties make the fallback safe rather than merely convenient:

  • the note is unconditional, so nobody learns their setting was ignored from a surprising outcome later;
  • the fallback tracks the era. While the default is report a typo costs nothing; once it is enforce a typo is safe by construction.

Refusing outright on a bad value was the alternative and is worse during the window: it makes a typo stricter than the default, which is the one behaviour nobody predicts.

EX_CONFIG. decisions/2026-08-25-exit-code-convention: the discriminator is whether retrying unchanged could ever succeed. None of these three can — the lock, the symlinks and the headers are all exactly as they were — so it is 78 and never 75.

make-findingprocedure

(axis headline details remedy). Kept a plain list so this module can stay inside BOOTSTRAP_MODULES without pulling in a record runtime. SEVERITY, and why one switch still needs two of them.

'refusable in enforce mode this blocks the build. 'advisory never blocks, in any mode. Reported, never enforced.

The floor-understatement axis (axis E — before 2026-08-25 the declaration-era range-intersection axis, redefined by decisions/2026-08-25-sigil-field-is-a-floor) is advisory because a declared-vs-declared comparison is a maintenance signal, not breakage; the resolved-vs-declared comparison that IS about breakage is the floor axis, and that one is refusable.

This is deliberately a severity on the FINDING rather than a second environment variable. Two independent switches is how an estate ends up with one of them permanently off; one switch, four axes, one flip.

*notes*variable

Notes accumulate across a run and are read back by coherence-notes. A note records an axis that DECLINED to compare and why. Without them a skipped axis and a passing axis produce identical output, and the estate has been bitten repeatedly by a check that was quiet because it never ran (topics/absent-tool-impersonates-a-verdict).

Compare two git object names that may be abbreviated to different lengths — (sigil-commit) reports 8 characters, a lockfile stores 40.

THREE-VALUED ON PURPOSE. 'unknown is not a quiet 'agree: the caller turns it into a NOTE, so an identity that could not be established shows up in the output instead of vanishing into a pass.

'agree — the common prefix matches and is long enough to mean it 'disagree — the common prefix differs 'unknown — one side is absent, too short, or not hexadecimal

(sigil-commit) is NOT always a bare object name. MEASURED, on this host, 2026-08-25:

installed release build -> "163dd929" dev build from a worktree -> "5fb9cd45-dirty"

A comparison written against only the first form returns 'unknown for every developer build — which would make axis A permanently, silently inert exactly where it is most often exercised. Split the object name off the front and keep the rest as provenance.

commit-dirty?procedure

True when the compiler was built from a tree with uncommitted changes. Such a compiler CANNOT be identified by its commit: the sha names what it was branched from, not what it is. An 'agree verdict on a dirty compiler is therefore approximate, and the caller says so rather than letting it read as proof (topics/kiln-version-string-is-not-a-binary-identity).

sigil deps install materialises a git dep at $SIGIL_HOME/deps/<forge>-<owner>-<repo>-<40-hex-sha> and points .sigil/deps/<name> at it, or at a packages/<pkg> subdirectory of it for a monorepo selector. So the sha that is ACTUALLY ON DISK is recoverable from the symlink target with no git, no network and no build — which is what makes t-979187's shape (a current lock beside ten-day-old symlinks) mechanically detectable.

Returns the 40-character sha, or #f when the target is not a deps-cache path at all. #f is a legitimate, common answer: a workspace member, a from-path dependency and a dev-redirect all resolve somewhere with no sha in the path, and none of them is an error. The caller must treat #f as "not comparable", never as "stale".

TWO CONSTRAINTS, BOTH FROM A REVIEWER'S FIXTURE THAT REFUSED A PERFECTLY COHERENT BUILD:

  1. RIGHTMOST match, not leftmost. The cache entry sits near the end of the path (only packages/<pkg> may follow it, and that carries no sha). Scanning from the left lets any ANCESTOR directory win — a project at /tmp/probe-<40hex>/… had its own directory name reported as the "installed" sha, over a cache path that agreed with the lock perfectly. The refusal even printed the correct cache path on its via: line while contradicting it on installed:. That shape occurs naturally: GitHub's /archive/<sha>.tar.gz extracts to <repo>-<40-hex>/, and CI workspaces are often named by commit.
  2. At least two more - before the sha, because the writer's format is <forge>-<owner>-<repo>-<sha>. This rejects the single-hyphen <repo>-<sha> shape above even when it IS the rightmost match.

What it still cannot distinguish: a directory genuinely named a-b-c-<40hex> that is not a deps cache entry. Nothing short of knowing $SIGIL_HOME can, and a wrong answer there is a refusal rather than a silent pass — the safe direction, and bypassable.

Where a dependency ACTUALLY lands, fully resolved. Distinct from dep-link-target on purpose, and the distinction is load-bearing in both directions:

  • axis B needs the LITERAL target, because the sha it reads lives in the name sigil deps install wrote, and realpath can resolve through it;
  • the workspace-member test needs the RESOLVED path, because a RELATIVE target such as ../../packages/sigil-lib never starts with an absolute project directory.

That second case is not hypothetical: this repository's own scripts/ci-relink-workspace-deps.sh writes relative links for sigil-lib and sigil-stdlib on every CI checkout. A reviewer demonstrated two fixtures identical but for link style, one exempted and one refused — so on CI the monorepo would have failed to build itself, with axis A telling the operator to sigil deps install a workspace member that no install will relink.

True when a dependency resolves to a source tree INSIDE the project — a workspace member. Both halves are required:

  • the resolved location is under the project, and
  • neither the literal target nor the resolved path is cache-shaped.

The second half stops a deps cache that happens to sit under the project (a container image, a vendored cache, a test fixture) from being mistaken for a workspace member and exempted from the very axis it needs.

Split a line into whitespace-separated tokens, collapsing runs of spaces and tabs. string-split on a single " " yields empty strings for a run, which is how a two-space #define slipped past the first version of the parser below.

Extract #define <name> <integer> from C header text.

TOKENISED rather than matched against a literal "#define <name> ". The literal form required EXACTLY one space in each gap, so #define SIGIL_ABI_VERSION 6 or a tab-separated define returned #f — and #f DISABLES the axis (it becomes a note, not a refusal). A clang-format pass over sigil.h would therefore have silently switched off the one axis that does not read the lockfile, with no visible change anywhere. Found by an adversarial reviewer; latent rather than live, because today's header uses single spaces.

Returns the integer, or #f when the define is absent or its value is not a plain decimal integer. A hex value (0x6) still returns #f: string->number would need a radix and guessing one is worse than saying so. The caller reports #f as a NOTE — a header this cannot parse is a reason to say so, not a reason to convict a build.

The resolved sigil-lib directory for this project, or #f. Follows the same resolution the build itself uses so this reads the tree that will actually be compiled, not a tree derived from the lock.

True when the operator has explicitly pointed a runtime package at a local tree via dev-redirects.sgl.

A redirect is a DELIBERATE override of the lock, and refusing on the grounds that it disagrees with the lock would refuse the one workflow the estate uses to develop the toolchain against a consumer. So axes A and B stand down for a redirected runtime and say so — axis C still runs, because it reads the redirect target itself and a redirect to an ABI-incompatible tree is still a dead build.

Fires when the compiler about to run was built from a different commit than the runtime this project has locked. That is t-136d0d, where a v0.18.3 compiler at da7cfd6f met a lock pinning 0ed19fd1 which ALSO reports 0.18.3 — invisible to every version comparison and visible to this one.

SKIPPED when sigil-lib resolves inside the project (a workspace member, a from-path dep, or a dev-redirect). There the lock's sha is not the authority over what will be compiled — the working tree is — and axis C reads that tree directly. Without this exemption the sigil monorepo cannot build itself.

t-979187's exact shape, and the one that reached a phone: the lock was relocked at 03:41, the .sigil/deps symlinks were ten days old, the deploy built at 06:05 with no sigil deps install in between, and the build printed "Build complete!" and exited 0. Nothing compared the two.

This compares every lock entry that pins a git sha against the sha embedded in the symlink sigil deps install wrote. It needs no git, no network and no build, so it costs nothing to run first.

Reads SIGIL_ABI_VERSION and SIGIL_SGB_VERSION out of the resolved sigil-lib's own include/sigil/sigil.h and compares them with what the running compiler reports.

WHY THIS IS NOT THE EXISTING LINK-TIME GUARD. abi.c emits a reference to sigil_abi_v<SIGIL_ABI_VERSION>, but that macro is resolved from whichever sigil.h is on the include path — the LOCKED sigil-lib's. Both sides of that comparison come from the same locked tree, so it compares locked-to-locked and never looks at the compiler at all. This axis is the half that was missing.

Measured, and why one number is not enough: v0.18.2 ABI 2, SGB 10 v0.18.6-v0.19.3 ABI 6, SGB 11 bureau at 0.18.2 trips both against a 0.19.2 compiler and dies with unsupported bytecode version 11 (expected 10). courier at 0.19.0 matches on BOTH and still dies, with invalid native function index — which is why axis A exists and why this axis does not pretend to be sufficient on its own.

One ABI-ish integer compared. Returns '() or a one-element list. A #f on either side is a NOTE, never a refusal — a header this cannot parse is a reason to say so, not a reason to convict a build.

The sigil version this project actually resolved, from the lock.

A dep's declared sigil: range, read from the manifest that will actually be compiled — the installed copy under .sigil/deps.

[[topics/sigil-017-pin-sweep-campaign]] warns "never read .sigil/deps", and it is right FOR A SWEEP: a sweep wants to know what each package's master declares so it can fix it, and an installed copy is an old release. This axis asks a different question — what will THIS BUILD compile against — and for that the installed copy is the only correct source. The two are not in conflict; they are different populations, and the report says which one it read.

REFUSABLE. A dep whose floor is above the resolved sigil — or whose measured ceiling is at or below it — is a dep being compiled against a runtime it has declared it cannot run on.

Both bounds come from sigil-field-admits?: floor <= resolved < ceiling. The ceiling is enforceable for bare values (implicit next major), (supports ...) forms (measured), and GRANDFATHERED carets (read as their stripped bare form — the caret tolerance, decisions/2026-08-25-sigil-caret-grandfather — whose 1.0 ceiling cannot bind below the flag day). Other legacy operatored values are FLOOR-ONLY — enforcing an old range's own ceiling is the expired-declaration failure this design removed (decisions/2026-08-25-sigil-field-is-a-floor).

REDEFINED 2026-08-25 (decisions/2026-08-25-sigil-field-is-a-floor). Until then this was the declaration-era axis: did each dep's declared RANGE intersect the host's? That measured how long ago a manifest was written, not compatibility — 808 findings across 149 checkouts, including the monorepo, which failed the predicate while building fine. The sigil: field is no longer a range at all, so the question itself is retired; anyone meeting a ^0.16 in an old branch should read the decision note above.

What remains meaningful between two DECLARATIONS is understatement: a dependency declaring a floor ABOVE the host's means the host's own sigil: understates what its dependency tree needs — a reader of the host's manifest would provision too old a toolchain. ADVISORY BY CONSTRUCTION: whether this BUILD is broken is the resolved-vs-declared comparison, which is axis D's job and refusable there. This axis is a work-list, not a wall, and one finding rather than N (the count is the headline, the list is the detail, --verbose is the door).

Run all four axes. Returns a list of findings; '() means coherent.

Every axis runs even after an earlier one fires. An operator who is told about one axis, fixes it, and is then told about the next has been made to pay for the same build twice. FAILS CLOSED. If the check itself cannot run — an unparseable lockfile is the realistic case — that is a refusal, not a pass.

This is the whole reason the module exists, stated as code: a check whose failure mode is "proceed quietly" is the defect it was written to remove. It is also correctly an EX_CONFIG condition — a lockfile that cannot be read will not read any better on a retry.

The refusal carries the exception text so the operator is not left guessing which of the three axes broke.

Print the mode note, if the variable held something unrecognised.

CALLED UNCONDITIONALLY, before the findings are consulted, and that ordering is the point. The predecessor of this function printed its note only from inside the branch it governed, so an operator who set the variable to an unrecognised value was told nothing at all and learned their setting had been ignored from a surprising outcome later. Found by this module's own gate.

report mode: say what was found, name the fix, do not block.

This is what the estate sees while the relock campaign runs, so it has two jobs at once — give the campaign a work-list, and not read as an accusation. Most people who see this have done nothing wrong: a git pull that touched sigil.lock is enough, and the fix is one command. COMPACT BY DEFAULT — one line per finding, full detail only under --verbose. This is not cosmetic.

The enforce-mode renderer prints five or six lines per finding, which is right when you are blocked and need to act. Reproduced verbatim in a mode that fires on EVERY build it would be a catastrophe: kiln has 24 findings, so every sigil build in that repo would open with about 150 lines of prose. Nobody reads that twice. They set the variable to off to make it stop — which is precisely the habit this rollout exists to avoid, arriving by way of the warning rather than the refusal.

A relock campaign wants a WORK-LIST. A work-list is a list.

enforce mode: refuse. The caller exits; this only prints.

The remediation doubts the check itself before it points outward. Every refusal in this estate that pointed outward without doing that has eventually pointed at the wrong thing — and this one has already earned the doubt: an adversarial review found three separate ways it refused a HEALTHY build, none of which its own gate had caught.

finding-axisvariable

(No description)

(No description)

(No description)

(No description)

(No description)

(No description)

(No description)

(No description)

(No description)