backend-skeleton
v1.9.0
Published
Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scans Spring, Rails, FastAPI, and Express; scaffolding codegen included for selected stacks.
Readme
backend-skeleton
bskel is a deterministic gate layer for AI-assisted (and human) backend work: before a change is
allowed to count as done, bskel checks it against disk — not against what an agent or a person
claims. Is this branch actually based on the real default branch? Does this "new" module collide
with one that already exists elsewhere in the codebase? Does the emitted contract still match the
source it claims to describe? Did a hand-finished file just get silently overwritten? Every one of
those is a gate backed by a content-hash on disk, not a prompt instruction a future session could
ignore, forget, or talk itself past.
Scaffolding codegen — Java/Spring Boot, Python/FastAPI, and TypeScript/JavaScript Express repos, feature_id-scoped machine-readable contracts, UUID-addressable field handles, and stack-choice (e.g. ngrok) wiring — rides on top of that same gate machinery. It's useful on its own, but the reason the gates exist first is what makes the codegen safe to trust in a brownfield repo, instead of just another thing to double-check by hand.
bskel exists because a previous ad-hoc agent-driven scaffolding attempt branched a worktree 658
commits behind the real default branch and never noticed. Every gate in this tool is a regression
check for a specific failure mode found the same way — see DECISIONS.md for the full record.

A real terminal, a real bskel binary, a real fixture repo — not a scripted transcript. Source:
docs/demo.tape, regenerated with docs/record-demo.sh.
Contents
- Status: 1.0.0
- Quickstart
- Try it in 10 seconds
- The gated workflow
- Pull-request gate checks
- Starting from nothing (greenfield)
- Publishing a feature's contract as OpenAPI (optional)
- A CSV table of a feature's contract (optional)
- Database schema (optional)
- An ERD of your database schema (optional)
- Applying DDL to a live database (optional)
- Splicing real Java source (optional, java-spring only)
- Declaring field-to-field dependencies (optional)
- Patching a config file (optional)
- Signed gate attestations (optional)
- Signed observe receipts (optional)
- Compatibility
- Generated-file policy
- Security model
- Troubleshooting
- What ships in the package
- License
Status: 1.0.0
As of 1.0.0, this project makes an explicit API-stability promise: bskel's CLI surface --
command/flag names and their meaning, every --json output shape (all schemas under schemas/),
gate names and pass/fail semantics, and exit codes -- is stable. A change that breaks any of
those requires a major version bump, never a patch or minor release; a new command, a new optional
flag, or a new additive field on an existing JSON shape is always minor-version-safe (every schema
under schemas/ is additionalProperties: false specifically so a genuinely new field shows up as
a real, visible schema change rather than something an existing consumer could silently miss). See
D-stable-api-contract in DECISIONS.md for the full policy and what's explicitly excluded from
it.
That promise is about the interface, not a claim that every subsystem has been proven in production -- which still splits the same way it always has:
scan,contract(includingexport), andneware the most exercised paths — real, measured verification against a real production Spring Boot repo (seeDECISIONS.md), plus a synthetic fixture corpus for every adapter, run in CI on every change.handles(the UUID-addressable resolver/codec/router codegen) is functionally complete and tested the same way, but has never been deployed to a real production repo. Two gaps named in earlier betas have since been partially closed:O3(opt-in--enforce-registry, checked fetch/patch/recover againstHandleRegistry, revocation-aware) andO5(fetch/patch now derive independently correct roles instead of silently sharing one) are both implemented -- seeDECISIONS.md'sD-handle-registry-enforcement/D-resolver-authorization-action-aware. Real gaps remain and are explicitly still open, not closed: registry enforcement is opt-in, off by default; authorization inference now recognizes both@PreAuthorize(hasRole(...))andhasAuthority(...)(seeDECISIONS.md'sD-resolver-authorization-action-aware) via an explicit per-action policy contract (D-resolver-policy-contract) -- a companion annotation this scanner cannot safely evaluate (@PostAuthorize,@Secured,@RolesAllowed, ownership/tenant checks,hasAnyRole/hasAnyAuthority) now refuses auto-materialization and generates anAuthorizationPolicyinterface that blocks the target app from starting until a human implements it -- ships default-on,handles emitexits non-zero (23) until acknowledged with--force --reasonor the policy is implemented.hasAnyRole/hasAnyAuthoritylist-shapes remain unaddressed (deliberately, seeD-resolver-policy-contract's own EXIT). All three providers (Java/Python/TypeScript) now generate a realsbf_handle/sbf_handle_snapshotschema and support--enforce-registry/recover()-- TypeScript's own registration mechanism is a higher-order wrapper function, not a decorator (no Java-AOP or Python-decorator equivalent exists in this ecosystem the templates could safely rely on; seeD-typescript-express-registry-parityinDECISIONS.md). Treathandles emit's output as a scaffold to finish by hand, not a production-ready subsystem, until a real deployment happens.
Quickstart
Try it in 10 seconds
npm install -g backend-skeleton # or: npx backend-skeleton <command>
cd <any-existing-repo> # must be a git repository -- that's the only requirement
bskel scan # zero flags: every module/controller/entity/enum this repo's
# adapter can see, unscored -- no preflight, no feature, no
# files written, no gate touchedRails projects are scanned statically by default. On a trusted Rails checkout, add
--runtime-routes to boot the application and use bin/rails routes --expanded as the route
source; initializers and application boot code will run.
That's a read-only look, not the gated workflow — for real feature work (collision-checked against a specific idea, contract-gated, codegen), see below.
The gated workflow
npm install -g backend-skeleton # or: npx backend-skeleton <command>
cd <target-repo> # must be a git repository
bskel doctor # what's on PATH, which scanner adapter detects this repo, and why
bskel preflight # confirms HEAD is actually based on the real default branch,
# not a stale/abandoned one -- required before anything else
bskel feature init --slug organization-management
bskel scan --feature 001-organization-management --terms organization
# brownfield-collision scan; refuses to proceed silently if this
# module already exists elsewhere in the codebase
bskel scan disposition --feature 001-organization-management --mode reuse --note "..."
# required once scan finds a collision/adjacent match
bskel contract emit --feature 001-organization-management
# feature_id-scoped JSON Schema contract, from real source annotations
bskel scan cross-feature-check --feature 001-organization-management
# refuses to proceed if this feature's resourceType/table/operationId
# collides with another feature -- required before handles emit
bskel handles plan --feature 001-organization-management
bskel handles emit --feature 001-organization-management
# UUID-addressable field handles + generated resolver code
bskel verify --feature 001-organization-management --build
# aggregates every gate's current status; --build also runs the
# target repo's own build wrapper (gradlew/mvnw/npm), if presentPull-request gate checks
bskel ci check is the read-only CI counterpart to verify: it computes the merge-base with a
PR base ref, selects changed active features, and runs the same gate/artifact/build calculation
and bskel next remediation. It never refreshes preflight, passes a gate, or runs remediation.
bskel ci check --base origin/main --build \
--summary-file "$GITHUB_STEP_SUMMARY" \
--sarif-file bskel.sarif--feature 001-a,002-b overrides automatic selection. Without it, changes confined to one or
more specs/<feature-id>/ directories select those active features; a non-document change outside
feature specs checks all active features; documentation-only diffs are an explicit successful
no-op. --json writes exactly one report document even when checks fail. SARIF generation only
creates a file; uploading it is your workflow's policy.
The repository root is also a composite action:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # ci check needs the PR base and merge-base locally
- uses: popixoxipop-collab/backend-skeleton@main
id: bskel
with:
build: 'true'
# Optional: the action output is only a local file. Uploading requires this explicit policy.
- uses: github/codeql-action/upload-sarif@v3
if: always() && steps.bskel.outputs.sarif-file != ''
with:
sarif_file: ${{ steps.bskel.outputs.sarif-file }}Use this action only on GitHub-hosted or otherwise trusted runners for pull requests; it executes
the checked-out repository's configured build command when build: 'true' is selected.
bskel status/bskel next are what you actually run over and over — real output, captured against
a fixture repo partway through the flow above, not written by hand:
$ bskel status --feature 001-organization-management
# Status: 001-organization-management
## Gates
- [PASS] preflight
- [PASS] scan
- [(not_run)] cross_feature (required-when-present, feature-scoped)
- [BLOCKING] contract
- [(not_run)] dependencies (required-when-present, feature-scoped)
- [(not_run)] handles (required-when-present, feature-scoped)
- [(not_run)] stack (required-when-present, repo-scoped)
- [(not_run)] patch_transactions (required-when-present, feature-scoped)
- [(not_run)] conformance (required-when-present, feature-scoped)
## Artifacts
- [OK] contract: specs/001-organization-management/contracts/001-organization-management.schema.json
## Next
- bskel contract waive --feature 001-organization-management --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature 001-organization-management --reason "..." if intentional # contract gate is awaiting disposition
## Optional, not yet run: handles, stackEvery gate line is a real, disk-verified check — the Next line is always the exact command to
unblock whatever's currently BLOCKING, so there's no separate doc to cross-reference mid-workflow.
Starting from nothing (greenfield)
Every command above assumes an existing Spring Boot or FastAPI repo. If you don't have one yet:
bskel new --stack spring --slug my-service # calls start.spring.io (network required), or:
bskel new --stack fastapi --slug my-service # a local starter template, no network call
cd my-service
# create a remote you own and push to it (e.g. `gh repo create --private --source=. --push`),
# then: git remote set-head origin --auto
bskel preflight # now resolvable -- picks up from the Quickstart abovebskel new deliberately never creates a remote itself and never auto-chains into preflight --
preflight requires a real origin remote with a resolvable default branch, which a brand-new
local-only repo doesn't have yet. See D-greenfield-bootstrap in DECISIONS.md.
Both stacks accept --name, --description and --project-version (the generated project's own
version -- --version is a global flag that prints bskel's). Beyond that the parameters differ,
because the two ecosystems do:
bskel new --stack spring --slug my-service \
--group-id com.acme --artifact-id billing --package-name com.acme.billing \
--java-version 21 --packaging war --add-dependencies actuator,postgresql
bskel new --stack fastapi --slug my-service \
--python-version 3.12 --port 9000 --license MIT --database postgres--add-dependenciesextends the baseline (web, data-jpa, security, validation, lombok).--dependenciesREPLACES it -- and if the result dropsweb,data-jpaorvalidation, you get a specific stderr warning naming what stops working downstream, then it scaffolds anyway.--group-id/--package-name/--artifact-idare validated locally against the Java package grammar. That isn't belt-and-braces:start.spring.ioacceptsgroupId=com.new(a reserved word) andgroupId=has spacewith HTTP 200 and hands back a project that cannot compile.--java-versionis checked againststart.spring.io's own live metadata, fetched on demand only when you pass a non-default value, never cached to disk -- for the same reason:javaVersion=99returns HTTP 200 and writesJavaLanguageVersion.of(99)straight intobuild.gradle.--databasepins a driver and nothing else -- no engine, session or connection code is generated, because that would bebskelinventing your domain.--type,--languageand--boot-versionare deliberately refused with a specific reason each (Maven/Kotlin scaffolds break this tool's own scanner and codegen assumptions; a badbootVersiongets an unusable HTTP 500). Runbskel new --stack spring --type maven-projectto see the actual explanation.
The full parameter list, the measured API-validation matrix behind that split, and the warning
behaviour are in D-greenfield-parameters in DECISIONS.md.
Remembering your own conventions across projects (optional)
If you start several projects with the same conventions, bskel new can record them into a
database you own -- never bskel's own state, never a shared store:
export MY_PATTERNS=postgres://localhost/my_patterns # once: run patterns/schema.sql against it
bskel new --stack spring --slug billing \
--java-version 21 --group-id com.acme --dependencies web,data-jpa,validation,flyway \
--record-pattern --pattern-database-url-env MY_PATTERNS
bskel pattern suggest --stack spring --pattern-database-url-env MY_PATTERNSpattern suggest prints what you've recorded, with per-value frequency, and a ready-to-paste
command line at the bottom -- it never runs bskel new for you and bskel new has no flag that
would accept a suggestion as a default. Every value in a generated project is still one you typed
in that invocation. Omitting --record-pattern/--pattern-database-url-env leaves bskel new
exactly as it is today. See D-pattern-accrual in DECISIONS.md.
Publishing a feature's contract as OpenAPI (optional)
bskel contract export --feature 001-organization-management --out openapi/organization.jsonRenders an already-emitted, gate-passing contract as a standalone OpenAPI 3.1 document — the
inverse of contract emit --openapi-file. Useful for a Swagger UI page scoped to one feature
instead of the whole repo, a client generator that can't follow $ref (an exported document has
none), or a mock server for one feature's operations.
It is a deliberately lossy, narrow projection, and it says so — every omission is disclosed
both in prose (info.description) and machine-readably (info.x-bskel-omitted). Nothing is
invented to fill a gap: an operation whose body shape the contract doesn't know gets a JSON
media-type entry with no schema rather than a fabricated one, and an operation with no per-status
source data still collapses its 2xx/4xx/5xx bodies into two unions rather than guessing a status
code.
Query/header/cookie parameters, security (plus the referenced security schemes), summary,
tags, per-status responses, and non-JSON request media types (e.g. multipart/form-data) are
emitted — but only when a real --openapi-file source document said something for that exact
operation, copied byte-for-byte, never reconstructed. security: [] is emitted when the source
document itself said [] (a genuine claim that no authentication is required); it is never
invented as a default. Where no source document was given, or it said nothing for an operation
(or a particular field of one), the key is simply omitted, meaning "unspecified." Operation-level
description is copied too, but opt-in only (contract emit --descriptions) — measured too
expensive to copy by default (real average 2,442.7 bytes/operation, larger than every other field
this projection copies combined). The same flag also copies a schema FIELD's own description/
example (a property's own annotation, not the operation's) one level deeper into request-body/
response/error/parameter/per-status/path-param schemas — title, plural examples,
externalDocs, xml, and deprecated stay unconditionally dropped either way (0 real occurrences
measured against the Team-IZ-Backend oracle).
Export refuses a zero-operation contract, refuses when the scan found a global path prefix the
contract's paths don't reflect (--allow-unprefixed overrides), and stamps every document with an
x-bskel-generated marker that contract emit --openapi-file then refuses to read back in —
reconciling a contract against its own export would make it confirm itself. See D-openapi-export
in DECISIONS.md.
A CSV table of a feature's contract (optional)
bskel contract export-csv --feature 001-organization-management --out organization.csvOne row per operation, opened by someone who will never read a JSON Schema — a PM reviewing scope,
a lead deciding whether a partial contract is good enough to waive. Fourteen columns, always, in
the same order: operation_id, verb, path, path_params, path_params_unverified, body,
request_body_required, request_body_fields, response_fields, error_fields, provenance, summary,
tags, security.
Every column is always present, even when every row leaves it blank — a scan-only contract
(no --openapi-file) never states summary/tags/security, and the blank columns are that
finding, not something to hide: export-csv prints exactly which columns are empty for every
operation, and why, on stderr. Dropping an empty column would make the file's own shape depend on
its content, so it never does.
Unlike contract export, this command is deliberately UNGATED — it works even when the
contract gate hasn't passed yet, because its single most valuable moment is reviewing a partial
contract to decide whether to waive it. An unreflected global path-prefix signal (the thing
contract export hard-refuses on) downgrades to a stderr warning here instead. --bom prepends a
UTF-8 byte-order mark for Excel-on-Windows, which otherwise mangles non-ASCII text; omit it for
pandas/csv.DictReader/diff, which don't want one. See D-contract-csv in DECISIONS.md.
Database schema (optional)
bskel scan --db additionally scans Flyway/Liquibase migration files (local only, no network).
Add --database-url-env <NAME> (naming an environment variable you've already exported, never
read from .env) for live, read-only Postgres introspection (information_schema/pg_catalog,
inside a BEGIN TRANSACTION READ ONLY) and a source-vs-live drift report. Both are informational
additions to the scan report — neither blocks any gate. See D-db-schema-plane in DECISIONS.md.
The same --db/--database-url-env flags, passed to bskel scan cross-feature-check, add a 4th
collision signal: a real live (or migration-file-derived) Postgres foreign-key edge whose two
tables are declared by two different features surfaces as a db_foreign_key finding, direction-
and confidence-scored the same way the existing NAME-identity signals are. Every fk_check in the
report also carries generated_at — when the underlying data was actually captured, so a
persisted/migrations-mode correlation (reused from an earlier scan, not a fresh connection) can
be judged for staleness rather than trusted blindly. See D-cross-feature-fk-inference in
DECISIONS.md.
An ERD of your database schema (optional)
bskel db erd --database-url-env BSKEL_DB_URL --schema public --out schema.mmdA Mermaid erDiagram of the database — paste it straight into a GitHub/GitLab/Notion/Obsidian
markdown file (fence it in ```mermaid) or mermaid.live and it renders
with no extra tooling. Works two ways:
- With
--database-url-env: a real, live Postgres introspection — full column types, nullability, and primary/foreign keys. - Without it: falls back to scanning Flyway/Liquibase
.sqlmigration files (no network, no credentials needed) — a real but degraded diagram, clearly marked as such in the file itself: every column types asunknown, noPKbadge appears anywhere, and a header block spells out exactly what's missing. Useful for evaluating the tool on a repo you don't have DB credentials for yet.
Two things this diagram deliberately does not guess:
- Composite foreign keys. Postgres's own
information_schemadoesn't retain which source column pairs with which target column once a foreign key spans more than one column — the raw data is a cross product that can include pairs that were never declared. Rather than draw a wrong relationship line, a composite FK collapses to one line labeled with all its source columns joined by+, with the ambiguity spelled out in a%%comment above it. (Composite primary keys have no such problem and render fully.) - 1:1 vs 1:N. The child side of every relationship is drawn as "zero or more," never "exactly one" — telling those apart needs a UNIQUE constraint check this tool doesn't perform. The header says so.
Whole-schema only in this version (no --feature/--tables filtering yet) — for a very large
schema, db erd prints a note above 40 entities rather than silently producing an unreadable
diagram. See D-db-erd in DECISIONS.md.
Applying DDL to a live database (optional)
bskel patch propose --kind ddl-apply extends the same propose/approve/apply/rollback lifecycle
config_apply uses to a second kind: hand-authored CREATE/ALTER/DROP TABLE/INDEX/SCHEMA
statements, run inside a real Postgres transaction and only COMMITted once the introspected
schema actually matches the declared postcondition — anything else ROLLBACKs automatically, never
partially applies:
bskel patch propose --feature 001-organization-management --kind ddl-apply \
--database-url-env BSKEL_DB_URL --sql-file migrations/add_tax_rate.sql --schema public
bskel patch approve --feature 001-organization-management --transaction <id> --reason "..."
bskel patch apply --feature 001-organization-management --transaction <id>
# a transaction that DROPs one or more tables requires retyping the sorted, comma-joined table
# name(s) as --confirm instead of the transaction id -- the same "type the resource name to
# delete" pattern GitHub uses for its own irreversible actionsRollback of an applied ddl-apply transaction is refused outright by design — the only path back
is a new, forward transaction with hand-written reverse DDL, never an automated revert. The
allowlist structurally excludes anything that can't run inside a transaction block (e.g. CREATE
INDEX CONCURRENTLY) and anything outside TABLE/INDEX/SCHEMA DDL. bskel serve
--database-url-env <NAME> [--sign-key <path>] [--require-sign-key] exposes the same lifecycle
through the browser UI's own propose/approve/apply routes; --require-sign-key refuses to start
the DDL surface at all unless a signing key was also given. See D-ddl-apply in DECISIONS.md for
the full design and every explicitly-deferred boundary (non-Postgres databases, connection
pooling, production safety rails).
Splicing real Java source (optional, java-spring only)
bskel patch propose --kind java-source-splice extends the same lifecycle to a third kind: a
closed, four-operation vocabulary for editing REAL, hand-written .java source --
replace-method-body, insert-method-body-prologue, replace-field-initializer, add-import.
A member is located by its language-guaranteed identity (a type's fully-qualified name plus, for a
method, its erased parameter types — the same rule javac itself uses to forbid two overloads
sharing one erased signature), resolved via a real JavaParser+Symbol Solver pass and cross-checked
by an independent, non-AST falsifier before anything is written. The postcondition is a real
./gradlew compileJava; a failure automatically restores the original file, never leaves a broken
one in place:
cat > splice.json <<'EOF'
{
"schema": "sbf.java-source-splice/1",
"file": "src/main/java/com/example/demo/domain/widget/application/WidgetServiceImpl.java",
"edits": [{
"op": "replace-method-body",
"locator": {
"type_fqn": "com.example.demo.domain.widget.application.WidgetServiceImpl",
"member_kind": "method",
"member_name": "updateWidget",
"erased_param_types": ["java.util.UUID", "com.example.demo.domain.widget.presentation.dto.UpdateWidgetRequest"]
},
"replacement": "{\n\t\treturn widgetRepository.save(findWidget(widgetId));\n\t}"
}]
}
EOF
bskel patch propose --feature 001-widget-management --kind java-source-splice --splice-file splice.json
bskel patch approve --feature 001-widget-management --transaction <id> --reason "..."
bskel patch apply --feature 001-widget-management --transaction <id> --confirm WidgetServiceImpl#updateWidgetEvery edit outside the four ops refuses outright (no member add/remove/rename, no signature/
annotation edits, no nested/inner/anonymous/local types, no multi-top-level-type files, no
apply-diff) — there is no general-purpose patch/diff applier here, only grammar-delimited,
independently-verified regions. Requires the bundled AST helper (bskel doctor reports readiness
under "java-source-splice prerequisites"). Python/TypeScript source splicing is not supported —
neither has an equivalent AST helper or whole-project compile check in this repo. See
D-java-source-splice in DECISIONS.md for the full three-mechanism node-identity design and
every explicitly-deferred boundary.
Declaring field-to-field dependencies (optional)
When one feature's data actually depends on another feature's (e.g. a WidgetDto.name that's
populated from OrganizationDto.taxRate), bskel dependency declare records that link and passes
the dependencies gate for it. Declaring a dependency also warns the source feature next time
someone re-runs bskel status/bskel next there, so a downstream consumer doesn't silently break:
bskel dependency declare --feature 001-widget-management --resource WidgetDto --field name \
--source-feature 002-organization-management --source-resource OrganizationDto --source-field taxRate \
--reason "widget display name mirrors the owning org's rate tier" [--memo "..."]
bskel dependency list --feature 001-widget-management --json
bskel dependency remove --feature 001-widget-management --resource WidgetDto --field name \
--source-feature 002-organization-management --source-resource OrganizationDto --source-field taxRate \
--reason "no longer coupled"Cross-feature impact graph: field-level change detection with a mandatory disposition (optional)
bskel dependency declare above records that an edge EXISTS; it does not, by itself, tell the
source feature when its own contract/resource shape actually changes in a way that breaks the
downstream side. bskel impact check/bskel impact accept close that gap -- an impact gate,
complementing dependencies, that blocks the feature that CHANGED (not just the one depending on
it) until every downstream impact has an explicit compatible/migrate/waive disposition:
bskel impact accept --feature 002-organization-management # capture the first baseline
# ... later, OrganizationDto.taxRate's backing file changes ...
bskel impact check --feature 002-organization-management --json # names the exact change_key + downstream feature
bskel impact disposition --feature 002-organization-management \
--change <change_key> --downstream 001-widget-management \
--mode compatible --reason "purely additive" # or --mode migrate --tracked-by "ISSUE-42"
# or --mode waive --expires-days 14
bskel impact accept --feature 002-organization-management # unblocked; advances the baseline
bskel impact check --all --json # the recommended CI invocation -- sweeps every featureA migrate disposition creates a real two-sided handshake: it records an INBOUND obligation on the
downstream feature (001-widget-management here), whose own impact gate stays blocked until it
runs bskel impact ack --feature 001-widget-management --from 002-organization-management --change
<change_key> --reason "...". Every disposition key embeds a hash of the NEW change, so a
disposition recorded for one shape never silently covers a later, different change to the same
field -- there is no wildcard. Only proven (exact-identity) impacts block; heuristic ones
(a guessed table name, a lower-confidence collision) are always reported, never blocking on their
own. Not a handles emit prerequisite (gated only at bskel verify) -- this repo's own
cross_feature-gate CI incident is why. See D-cross-feature-impact-graph in DECISIONS.md.
bskel impact export --format graphify|json|mermaid [--out <path>] [--focus <node-id>] [--rings N]
is the one LLM-free seam to an exploration layer: --format graphify writes the locally-installed
graphify skill's own native extraction file shape directly (no install/detect/extract steps, no
LLM call, no network) -- a consumer runs graphify.build.build_from_json() + cluster() +
to_obsidian() on it to get a browsable Obsidian vault of the whole cross-feature graph.
--focus/--rings write a Focus+Context data projection (ring/detail, inspired by Lamping/
Rao/Pirolli's 1995 hyperbolic-tree technique) into the export only -- never read back by any gate.
bskel serve [--port N] [--host <addr>] starts a small local HTTP server (loopback-only by
default, matching this project's "safe default, explicit override" convention) that serves a
read-only browser UI at / for the whole repo's dependency graph, backed by GET /api/graph. The
UI's own POST/DELETE calls to /api/features/:id/dependencies go through the exact same
declareDependency/removeDependency functions the CLI above uses — nothing is duplicated
between the two — and, like every other mutating command in this project, both take a JSON body,
not query parameters. GET/HEAD responses carry Access-Control-Allow-Origin: *; the mutating
routes never do, so only same-origin requests (the bundled UI itself) can write.

The page's own header describes it honestly: "Not a redesign of the original Fieldwire mockup --
this exists to prove the API actually works, nothing more." It's a minimal read-only check page, not
a polished dashboard — every table on it comes straight from GET /api/graph.
Patching a config file (optional)
bskel stack apply's config_check sometimes reports needs-manual-patch — a target file exists
but isn't wired up the way the chosen stack (e.g. ngrok) needs. bskel patch propose/approve/
apply/rollback closes that gap for the catalog entries that declare a machine-applicable fix,
using a comment-preserving edit with a content-addressed preimage check (refuses to apply if the
target changed since you approved it) and a real rollback:
bskel patch propose --feature 001-organization-management --choice ngrok \
--target src/main/resources/application.yaml
bskel patch approve --feature 001-organization-management --transaction <id> --reason "..."
bskel patch apply --feature 001-organization-management --transaction <id>
# ...or, to undo: bskel patch rollback --feature 001-organization-management --transaction <id> --reason "..."bskel patch list --feature <id> shows every transaction and its status. See
D-patch-transactions in DECISIONS.md.
Signed gate attestations (optional)
bskel gate export already produces a CI-independent report of every gate's current status,
including a live verdict RECOMPUTED at export time (so a gate whose stored record still says
pass but whose inputs have since changed is honestly reported as stale, never silently signed
as passing), the tool's own version, git tree identity, unconditional artifact hashes, and a
forced/revoked/waiver roll-up. Add --sign --key <privateKeyPath> to detached-sign it (Ed25519,
via Node's own crypto module — no new dependency), then verify it offline, on any machine,
without network access or trusting whatever produced it:
bskel attest keygen --out ~/.bskel-keys # writes attest-private.pem (0600) + attest-public.pem
bskel gate export --feature 001-organization-management \
--sign --key ~/.bskel-keys/attest-private.pem --out attestation.json
bskel attest verify --file attestation.json --pubkey ~/.bskel-keys/attest-public.pem--sign refuses a dirty working tree (same --allow-dirty convention bskel preflight already
uses) — the acknowledgement, if you pass it, is recorded inside the signed payload itself, not just
a flag you happened to type. attest verify's exit code reflects signature validity only — whether
the gates inside actually passed is a separate, printed summary. Three opt-in, default-off checks
narrow what "valid" is allowed to mean for your use case without touching that exit-code contract:
--expect-head <sha> (refuse an attestation about the wrong commit), --max-age-minutes N
(refuse a stale one), --reject-dirty (refuse one signed over an acknowledged-dirty tree) — each
failing exits 22, distinct from 1 (signature invalid), so "authentic but not what you asked
for" is never confused with "not authentic". See D-gate-attestation-signing and
D-attestation-payload-completeness in DECISIONS.md.
Signed observe receipts (optional)
bskel observe emit generates opt-in runtime middleware that checks real traffic against a
feature's contract and logs a verdict-only receipt per call (JSON Pointer + constraint kind, never
an observed value); bskel observe import --receipts <path> turns a stream of those receipts into
a committed report backing the conformance gate. By default a receipts file is trusted at face
value once it's structurally valid — a human could hand-fabricate one. Add --pubkey <path> to
observe import to verify each receipt's optional signature instead, reusing the same
bskel attest keygen-generated keypair signed gate attestations use:
bskel attest keygen --out ~/.bskel-keys # same command as above -- one keypair, multiple uses
# then, per deployed app (one-time, at the app's own startup):
# TypeScript: import { setSigningKey } from './observe/receiptSign'; setSigningKey(pem);
# Java: set the bskel.observe.signing-key-pem Spring property (e.g. an env var)
# Python: receipt_sign.configure(os.environ.get("BSKEL_OBSERVE_SIGNING_KEY_PEM"))
bskel observe import --feature 001-organization-management --receipts receipts.jsonl \
--pubkey ~/.bskel-keys/attest-public.pem [--require-signature]Unset/no key configured means every receipt stays unsigned — fully backward compatible with every
app already using this feature. --pubkey alone verifies signatures where present and tolerates
unsigned receipts (excluding them from the report's matched counts, with a printed warning);
--require-signature makes any unsigned or invalid receipt abort the whole import. See the
"cryptographic receipt attestation" update in D-runtime-conformance-receipts in DECISIONS.md.
Every command is read-only until you explicitly run one of the mutating steps above — bskel
status/bskel next (no arguments needed) tell you which gate is next and print the exact
copy-pasteable command for it, without touching anything.
The full gated workflow, what each phase writes, and every flag is documented in SKILL.md
(present in this repository, not in the installed npm package — see "What ships in the package"
below).
Compatibility
| Requirement | Constraint | Why |
|---|---|---|
| Node.js | >=18 | ES2022 (Object.hasOwn) + ESM top-level await — nothing newer is used anywhere in the runtime code (verified by grep across every recent-ES-addition pattern; see D-npm-packaging in DECISIONS.md) |
| git | required | every gate is git-state-derived |
| ripgrep (rg) | required for scan/handles | every scanner adapter shells out to it at detect() time behind a blanket try/catch — traced live, missing rg does NOT throw, it silently makes every real adapter detect nothing (degrades to the low-confidence generic-grep fallback). bskel scan's report now carries a rg_available: false field plus an explicit unknowns warning whenever this happens, so it stays distinguishable from a genuinely-unrecognized repo — see D-zero-config-scan in DECISIONS.md |
| gh (GitHub CLI) | optional | only used for preflight's 3-way default-branch cross-check; already soft-guarded, never a hard requirement |
| python3 | optional | only needed to run this repository's own cross-language codec test — bskel itself never invokes python3 |
| a build wrapper (gradlew/pom.xml+mvnw/package.json) | optional | only bskel verify --build needs one; handles emit never compiles anything itself |
Run bskel doctor in any target repo to see exactly which of these it found, with a remediation
string for anything missing.
Supported scanner adapters (auto-selected by specificity, never hardcoded — see
D-adapter-registry in DECISIONS.md):
java-spring— Spring Boot (build.gradle/pom.xml+src/main/java). Full capability set: operation extraction, request-body detection, and a real codegen provider forhandles emit.ruby-rails— Rails 8 (Gemfile+config/application.rb+config/routes.rb). Statically expands conventional routes/resources and extracts ActiveRecord table/primary-key metadata. Operation ids are deterministic bskel syntheses, not source declarations;--runtime-routesexplicitly boots the trusted application for authoritative framework routing. Scanner only: request-shape extraction and handles codegen are not supported.python-fastapi— FastAPI + SQLModel. Real codegen provider forhandles emit; contract-grade operation extraction is not supported (FastAPI generates operation ids at runtime) — pass a real OpenAPI document via--openapi-filefor a trustworthy contract.typescript-express— TypeScript + Express + TypeORM. Real codegen provider forhandles emit(entities come from@Entity/@PrimaryGeneratedColumn); no operation extraction — plain Express has no operationId concept, so pass--openapi-filefor a contract. SeeD-typescript-express-providerinDECISIONS.md.javascript-express— plain-JavaScript Express, both ESM and CommonJS, with no ORM (rawmysql2/mariadb), includingserverless-http/Lambda deployments. Scanner only — routes and their real absolute paths are resolved through a full mount-graph walk (including direct CommonJSrequire()mounts andmodule.exports), but every capability is honestlyfalse: there is no codegen provider, because raw SQL string literals carry no trustworthy table/primary-key/column-allow-list metadata. SeeD-javascript-express-adapterinDECISIONS.mdfor the measured reasoning.generic-grep— unconditional last-resort fallback (Express/Flask/FastAPI-shaped route detection). Reconnaissance only, never contract-grade — alwaysconfidence: "low", requires--accept-low-confidenceto proceed past a feature-scoped scan.
Generated-file policy
bskel handles emit writes real Java/Python source into your repository. Two things are always
true about what it writes:
fetch()is wired to a real, existing, already-tested read-only service method — never hand-written business logic. It's generated only when a matching<Entity>Servicemethod exists and takes exactly the one resource UUID argument a resolver always passes (a mismatch there means "no resolver generated", not "generate one and hope", since silently calling the wrong overload can drop a required scoping argument — seeD-security-8inDECISIONS.md).patchField()is always a stub. Real codebases mix at least three different partial-update DTO conventions; guessing wrong would silently bypass real validation. A human finishes this by hand, every time.
Reruns are safe by construction, not by convention (D-handles-ownership in DECISIONS.md):
safety is derived from the generated file's actual on-disk content, not from a manifest that
might be absent (a fresh checkout, CI, or a repo that doesn't commit .sbf/). A file that diverged
from what bskel generated (a hand-finished patchField(), someone else's edit) is never silently
overwritten — it reports a conflict, and the escape hatch (--force --reason "...") is always
audited, never silent.
Security model
bskel generates code that runs in production, so it was put through an adversarial security
review (Codex, security-only lens, independent of the build process) — 10 numbered items, all
fixed, each with an inline D-security-N comment at its exact location in the code (the last item
bundles three additional lower-priority fixes from Codex's own broader "other things worth
checking" pass). Highlights (full record in DECISIONS.md's "Security hardening pass" section):
- Prototype-pollution guards everywhere a user-controlled string indexes a plain object
(
operation_idvalues like"constructor"/"__proto__"are rejected, not silently resolved via the prototype chain). - Path-traversal containment on every stack-catalog-driven file write (
--choice, and every catalog entry's own declared template/path fields). - No predictable temp files, no silent permission downgrades in the generated bootstrap
scripts that touch
.env(mktemp+ an unconditionalchmod 600). - Authority derivation is per-method, not per-file — a controller's first
@PreAuthorizematch no longer silently applies to every resolver generated from that file; an unsupported annotation shape (hasAnyRole, SpEL) fails closed to aTODO_ROLEplaceholder rather than guessing. - A companion authorization annotation (
@PostAuthorize,@Secured,@RolesAllowed) next to an otherwise-safe@PreAuthorizerefuses auto-materialization entirely (D-resolver-policy-contract) — a real, closed IDOR:@PreAuthorize("hasRole('USER')")sitting beside@PostAuthorize("returnObject.ownerId == authentication.name")used to auto-materializeROLE_USERand silently ignore the ownership check.handles emitnow generates anAuthorizationPolicyinterface that blocks the target app from starting (not the build) until a human implements it. - Handle recovery cross-checks type/kind/pointer against the registry row, not just the raw
UUID — the most severe finding: an attacker who controls the handle's
typefield could otherwise request a different, more sensitive resource's snapshot history that happens to share the same UUID.
Troubleshooting
Start with bskel doctor — it names exactly which required tool is missing and why, or run bskel
status/bskel next to see which gate is currently blocking and the exact command to resolve it.
Every command shares one exit-code table (lib/exit-codes.mjs) and, with --json, an additive
diagnostic envelope on payload-less early exits — the number is the stable contract, reason in
the envelope is supplementary precision:
| Exit | Meaning |
|---|---|
| 0 | OK |
| 2 | a required gate hasn't passed yet, or a referenced resource/adapter/provider doesn't exist (--json's reason field disambiguates which) |
| 3 | a gate is awaiting a disposition decision (bskel scan disposition/bskel contract waive) |
| 4 | a gate is stale — either an input actually changed, or (preflight only) the pass is simply too old |
| 10 | not inside a git repository |
| 11 | preflight: HEAD is behind the real default branch |
| 12 | preflight: the three independent sources for "what is the default branch" disagree, or none could be determined |
| 13 | preflight: uncommitted changes present (--allow-dirty to override) |
| 14 | bad arguments |
| 16 | a low-confidence scan was blocked (--accept-low-confidence to proceed anyway) |
| 17 | the selected adapter/provider doesn't support a capability the command needs |
| 18 | preflight: a git fetch was attempted and failed (--offline to accept a local-only verdict instead) |
Common cases:
preflightfails withSTALE_BASE: your branch really is behind —git worktree add <path> -b <branch> origin/<default-branch>(or rebase in place), then re-run.scanexits16: the scanner fell back togeneric-grep(low confidence, no real parser). If this repo actually is Java/Spring or Python/FastAPI-shaped, runbskel doctorfirst — it explains exactly why the real adapter didn't detect it, rather than reflexively passing--accept-low-confidence.handles emit/contract emitexits17: the adapter that scanned this repo doesn't declare the capability that command needs (e.g.generic-grepnever declarescodegen.handles— there's no codegen provider for a route-pattern-only stack).bskel doctorlists every installed adapter's declared capabilities.- A previously-passed
preflightnow reports stale withttl_expired: passes expire after 30 minutes by default (data-derived, seeD-preflight-freshnessinDECISIONS.md) — re-runbskel preflight, or pass--max-age-minutes 0to disable the TTL for a deliberately long-running or offline session.
The full exit-code/reason taxonomy, global flags (--help/--version/--json/--quiet), and
the complete gated-workflow reference live in SKILL.md and DECISIONS.md in this repository.
What ships in the package
npm install ships only what bskel reads at runtime — bin/, lib/, contracts/,
scanners/, handles/ (including every codegen template), stack/ (including the catalog and
bootstrap templates), schemas/, and scripts/preflight-base-ref.sh. This repository's own test
suite, SKILL.md (this project's Claude Code skill definition), DECISIONS.md, and CATALOG.md
are not part of the published package — clone this repository directly if you want those.
License
Dual-licensed: AGPL-3.0-or-later for open-source use, or a
commercial license for closed-source/proprietary use without AGPL's
copyleft obligations. See COMMERCIAL-LICENSE.md
for why, and how to obtain one.
