Self-hosting: the engine gates its own tree#
bce grades repositories against an authored EngineeringBlueprint. This repository is
itself a repository — so it carries its own blueprint
(.blueprints/engine.blueprint.json) and CI runs
the engine over the engine on every push and pull request
(.github/workflows/self-gate.yml).
Self-hosting is not a stunt. It is the cheapest continuous proof that:
- the engine's authored-artifact path (schema → extraction → evaluate → score → verdict) works end-to-end on a real, non-trivial TypeScript repository;
- the engine's own architecture cannot silently drift — the same fail-closed gate its users get is the gate its maintainers live under;
- dogfooding surfaces real engine gaps (see the
teethscopePaths fix below, found the first time the engine assessed its own blueprint).
The architecture this blueprint enforces#
Every constraint below was verified against the code before being authored — the blueprint records the real architecture, not an aspiration.
| Claim | Constraint(s) | Mechanism |
|---|---|---|
Only src/extractors.ts may import ts-morph. Everything else consumes extracted facts through the seam (below). | only-extractors-may-import-ts-morph | forbiddenDependency on the AST import graph, scopePaths = every src file except extractors.ts |
Only src/python-module-graph.ts may import the pinned Lezer Python parser packages. | only-python-module-graph-may-import-lezer | forbiddenDependency on the AST import graph, scopePaths = every src file except python-module-graph.ts |
Runtime dependency allowlist is exactly zod, ts-morph, @lezer/common, and @lezer/python. Any other external package import is a violation. node: builtins are allowed globally and narrowed per-file below. | runtime-dep-allowlist-approved-parsers | anchored per-line forbiddenPattern over src/**/*.ts |
src/schema.ts (the single source of truth for the artifact shape) imports only zod plus the local safe-regex guard. | schema-imports-only-zod-and-safe-regex | per-file forbiddenPattern |
The evaluator is pure: report.ts may import only node:crypto (deterministic hashing) + local modules; score.ts and teeth.ts import local modules only. No fs, no network, no process, no child processes. | evaluator-pure--* (3 constraints) | per-file forbiddenPattern |
| Proposal compilation and review semantics remain pure; only the assistant, SCM-authentication, quarantine, and CLI shells own I/O. | review-core-no-io-imports plus explicit component relationships | per-file forbiddenPattern + authored graph |
Only the two process-owning bins (cli.ts and mcp-server.ts) are exempt from the no-exit rule; every one of the 39 library modules returns or throws. | only-cli-may-call-process-exit--* (39 per-file constraints) | per-file forbiddenPattern |
Design notes, recorded honestly:
- Per-file constraints instead of glob negation. A
forbiddenPatterncarries onepathglob and the glob language deliberately has no negation ("every file exceptcli.ts" is not expressible). The blueprint therefore enumerates per-file constraints. The obvious gap — a new src file arriving with no per-file constraint — is closed by construction:tests/self-blueprint.test.ts(run inside the self-gate workflow) fails whenever the actualsrc/*.tsset and the blueprint's coverage disagree, includingextraction.minFiles, which is pinned to the exact file count as a fail-closed scan floor. - What the content patterns can and cannot see. The import-allowlist patterns are
anchored single-line matches (
import … from 'x',export … from 'x', and the multi-line closer} from 'x'). They do not see a dynamicimport('x')expression. Thets-morphseam constraint does not rely on them — it rides the AST import graph. - Purity of the evaluator vs. wall-clock/randomness. A content constraint forbidding
Math.random(/Date.now(in the evaluator would false-fire today: those tokens appear in the evaluator's own documentation comments (they describe the mock-detection tooth). Determinism is instead proven behaviorally by the determinism test suite. Recorded here rather than silently narrowed.
The extractor / facts seam#
The blueprint never talks to ts-morph. It talks to facts:
RepositoryFactsExtractor(src/graph.ts) is the seam interface:extract(repoDir, revision) → ArchitectureGraph. The provider registry selects the mature TypeScript AST, bounded line-scan, legacy Python import-surface, or structured Python module-graph implementation. A provider refuses, loudly, any extractor mode or constraint surface it cannot honor.ArchitectureGraphis the entire fact surface the evaluator sees:components,guardEdges(imports / provides / egress edges), andcoverage(scannedFiles,patternScan,unsupported— the declared-honest envelope).- The blueprint's
extractionblock drives the extractor:profile,paths,minFiles; everyforbiddenDependency.tois auto-unioned into the forbidden-import scan set, and everyforbiddenPattern.patterninto the content scan set — a constraint can never be silently unscannable. - Adding a new language or surface means implementing the same interface and emitting the
same graph shape;
evaluate(),score,teeth, and the CLI are unchanged. That is the plugin seam this repository's own blueprint exercises end-to-end.
Lane A / Lane B#
Two lanes can gate this tree:
- Lane B (live now) —
self-gate.ymlbuilds the engine from the commit under review and runsgate+teeth+ the sync test with it. Fail-closed: any non-pass verdict, toothless blueprint, or sync drift fails CI. - Lane A (live) — the last published engine, installed from the public registry at an exact version pin, gates the tree. Lane A is independent of the code under review, so it cannot be fooled by a defective change to the engine itself.
The bootstrap-0 exception ended with the provenance-backed bce-engine@0.1.0 publication.
Lane A now installs the exact bce-engine@0.3.1 registry artifact independently, while Lane B continues to grade
the commit under review with its own build.
The flip: the lane-a-pinned-gate job already exists in
self-gate.yml — it reads the exact pin from
.engine-pin.json, and is if-guarded on the pin being published AND
bce-engine@<pin> actually resolving on npm. The guard is open: the job installs
bce-engine@0.3.1 (exact pin, no range) and runs the same gate verb. The job is also a required
branch-protection check.
The Lane-A pin ceremony (forward reference)#
The Lane-A flip is a pin ceremony, not a version range — and the distinction is the whole point of the lane. Lane A must be independent of the code under review, so it installs an exact version:
bce-engine@0.3.1, never^0.3.1or~0.3.1or@latest. A range would let a later publish silently change the gate the tree is graded by, reintroducing the trusting-trust hole Lane A exists to close.- The pin is bumped only by a deliberate, reviewed PR — the same way any load-bearing dependency pin moves. Bumping the Lane-A pin is a governed act, recorded in the diff, because it changes the independent grader every contributor is measured against.
- The pinned engine is the published artifact from the public registry, so a defective change to the engine in the PR under review cannot influence the Lane-A verdict: Lane A rebuilds nothing from the branch.
The exact steps are written up in docs/pin-ceremony.md, and the live exact pin
is recorded in .engine-pin.json. On a pull request, Lane A selects that file
from the event's exact base SHA; on push, it selects the merged tree. v0.1.0 completed bootstrap;
subsequent pin bumps are therefore admitted by the previously published Lane-A engine.
Branch-protection incident policy (attended recovery, not a skip flag)#
A required, fail-closed check can occasionally block its own fix — the classic case is a change that reddens the gate whose only correct resolution is that very change (a corrected gate, a fixed extractor). This repository's current branch protection applies required checks to administrators; there is no ordinary admin-merge bypass. Recovery therefore requires an explicit, temporary change to branch protection (or a separately reviewed forward fix), and its policy is pre-written, not improvised:
- A temporary protection change is attended — a human decides it, in the moment, with the red in front of them, records the before-state, and restores that exact state immediately after the forward fix lands.
- Every such bypass produces a mandatory public incident record in this repository: what was red, why the bypass was the correct forward action, and what re-greened the check afterward.
This is an attended recovery path, not a skip flag — and the distinction is exact. bce's "no skip
flag" claim is a claim about the engine: there is no --skip / --no-verify / --force in bce
that turns a red green (the test suite asserts it). It is not a claim that repository administrators
cannot reconfigure GitHub. The incident record and exact restoration are what keep that platform
recovery visible when it is used.
Reproducing the self-gate locally#
npm ci
npm run build
node dist/cli.js validate --blueprint .blueprints/engine.blueprint.json
node dist/cli.js gate --repo . --repo-name blueprint-conformance/bce
npm run test:self-teeth-mutations
npx vitest run tests/self-blueprint.test.ts
Expected: blueprint VALID, gate score 100 (pass), and extractor-real-proven with all 47
constraints killed by 47 separately materialized source-tree mutants. The mutation manifest is
regenerated from the blueprint and is freshness-checked before the real CLI proof runs. Tests are
green. To watch the gate actually bite, add import { Project } from 'ts-morph'; to
src/score.ts and re-run the gate: it exits 1 with two violations (the seam constraint,
via the AST import edge, and the evaluator-purity pattern) — then revert.
What self-hosting already found#
Assessing the engine's own blueprint surfaced a real engine gap on day one: the
teeth reddening mutation for forbiddenDependency injected its synthetic edge at a
placeholder path that could never match a scopePaths-narrowed constraint, so a
genuinely enforcing, scoped constraint was mislabeled TRIVIALLY_GREEN. Fixed in
src/teeth.ts (the injected edge now lands at a concrete in-scope path) with a
discriminating regression test in tests/self-blueprint.test.ts. The former evaluator-only gap is
now closed by .blueprints/engine.teeth-mutations.json: every self-blueprint clause maps to one
real create/replace/append/delete mutation, and the CLI refuses missing, duplicate, surviving,
out-of-scope, protected-surface, syntax-invalid, or collateral mutations. This proves the current
47 clauses can bite the current extraction/evaluation path; it does not prove the blueprint is a
complete specification of every desirable property.
Full self-adoption#
The source checkout installs its canonical skills under .agents/skills/ and runs its built MCP
server through .mcp.json and .codex/config.toml. CI checks the installed skills for drift and
exercises the actual project MCP configuration. The enforced mode is explicit in .bce-mode.json.
The solo-steward ceremony describes the live GitHub decision path.
.bce-governance.json identifies the steward; it establishes no independent review. The acceptance
check for a completed lifecycle is node dist/cli.js doctor --repo .: real-source teeth, matching
approved policy and adoption history, exact CI pin, project integrations, and full gate must agree.
A missing ceremony remains a warning until its authenticated history exists. Both blueprints now
carry digest-bound source mutation manifests: 47 engine clauses and 13 skill-standard clauses.
The latter includes exact file evidence for forbidden files, without inventing line numbers.