ADR 0001: Foundation
Date: 2026-08-01 Status: accepted
Context
The product contract is docs/requirements/rebuild-requirements-2026-08-01.md. That
document describes a multi tenant, PostgreSQL backed, adapter driven platform delivered
across six phases. The immediate need is narrower and concrete: an inventory of products
and components that can be searched and filtered, a graph view of how they relate, and a
report that tells leadership where the CRA effort actually stands.
Those two things are compatible if they are separated correctly. The part that is expensive to change later is the data model: the many to many graph, claim provenance, the authority ladder, and the separation between an observation and a decision. The part that is cheap to add later is infrastructure: multi tenancy enforcement, access control, a plugin SDK, a job runner, and a Postgres migration.
There is also a problem the requirements document handles only indirectly. Several organizations each hold their own definition of what a product is. The ship room, the legal department, the commercial catalog, and the regulatory reading of the CRA do not agree, and they are not going to agree before the inventory has to exist. A model that requires one reconciled definition before it can hold data will not survive contact with the real portfolio.
Decisions
1. Build the full logical model, ship a thin surface
Implement the section 7 logical model in the first migration, including the tables that v1 does not exercise, such as jobs, sync runs, and campaigns. Ship only two user surfaces first: the inventory with search and status, and the leadership report, plus the relationship graph described in decision 6.
Rationale: the schema is the part that is painful to retrofit. Empty tables cost nothing. A claim row that was written without provenance cannot have provenance added later.
2. Scope lenses are first class, and conflicts are preserved
A scope_lens is a named definitional authority. Four are seeded: cra.regulatory,
commercial, shiproom, and legal.oss. Scope status for a subject under a lens is
stored as a claim with claim_kind = 'scope.status' and lens_id set, so it inherits
the authority ladder, evidence links, validity dates, and conflict retention that every
other claim already has.
A product can be in scope under one lens, out of scope under another, and undecided under a third at the same time. That is a normal state and not an error. Any number that is reported to leadership must name the lens it used as its denominator.
Rejected alternative: boolean flags such as is_cra_scope on the product row. It loses
the rationale, the evidence, the authority, and the disagreement, and every report then
has to hardcode which flag it trusts.
Rejected alternative: a separate product registry per organization with crosswalks. It is honest about the disagreement but doubles the reconciliation work and makes a single consolidated answer hard to compute.
3. SQLite, single file, no Postgres path in v1
The database is one SQLite file at data/crane.db, override with CRANE_DB.
Rationale: this runs on one machine, for one operator. Being able to open the file in any
SQLite browser and read the data directly is a feature, not a compromise. The requirements
document allows SQLite for local single user deployments. All access goes through
src/crane/db.py, which enables foreign keys per connection, so a later move to
Postgres is a matter of changing that module and translating the migrations.
Consequence accepted: no concurrent writers, and no row level security.
4. No authentication and no access control, but actors are still recorded
Superseded by ADR 0002. The reasoning below held while this ran on one laptop. It stopped holding when it started running in a container with a published port, and the second half of it — attribution without access control — is what 0002 keeps.
There is no login, no roles, and no permission checks. An app_user row named local
exists so that manual assertions, approvals, and state changes can name a responsible
actor in history. audit_log records mutations.
Rationale: RBAC protects a shared deployment. This is a single operator on a laptop. History and attribution are still worth keeping, because the audit trail is the product.
5. Every core table keeps tenant_id, with no enforcement
One tenant row named local is seeded and every core table carries tenant_id with a
foreign key, but nothing enforces isolation.
Rationale: the column costs nothing now and removes a full schema rewrite if this ever moves to a shared deployment. Enforcement is a data access layer change at that point, not a migration of every table.
6. The relationship graph is a v1 surface, not a later drill down
Requirements section 13.6 treats the relationship explorer as a drill down. It is promoted to a first class v1 surface because the fan in and fan out shape of the portfolio is one of the things that is hardest to explain in a table.
Three outputs, one query layer:
GET /api/graph.jsonreturns nodes and edges with relationship type, source kind, confidence, and review status on every edge.- The web explorer renders that JSON. The rendering library is vendored into the repository rather than loaded from a CDN, so the tool works offline.
crane graph export --format dot|mermaid|jsonwrites the same graph for pasting into a document or a leadership deck.
Edges are never silently merged. Two sources that disagree about the same relationship produce two edges, and the explorer shows both.
7. Plain SQL migrations, applied in order, immutable once applied
Migrations are numbered .sql files in migrations/, applied in order and recorded in
schema_migrations with a checksum. Editing a migration that has already been applied is
refused by the runner. Fix forward with a new migration.
Seed data ships as migrations too, including the lens definitions, the vocabulary registry, and the authority ladder. Those are data that changes over time and each change deserves a version and an applied timestamp.
Rejected alternative: Alembic. It pulls in SQLAlchemy, hides the SQL, and makes the database harder to inspect by hand, which defeats the reason for choosing SQLite.
8. FastAPI with Jinja templates, no build step
Server rendered pages, no JavaScript framework, no bundler. The same application exposes the REST API that requirements section 14 asks for.
Amended 2026-08-02: this originally said “and HTMX”. No HTMX was ever added — full page loads and one hand written file for the graph turned out to be enough, and the dependency was never worth taking. The decision that mattered, no build step, is unchanged.
Rationale: it runs as one local process, it grows into the nine section navigation later, and there is nothing to compile before looking at a page.
Rejected alternative: Streamlit. Faster to a first screen, but weak for governed write operations such as approving a mapping, and it would be thrown away rather than grown.
9. ossbomer is the SBOM validator, CRAne stores the outcome
CRAne does not implement SBOM conformance scoring. It calls ossbomer, which already
ships profiles for EU CRA, NTIA, CISA 2025, BSI TR-03183 and others, and stores the
result as an evidence_validation row plus a derived sbom.conformance claim.
Rationale: the conformance question is already solved in a tool that is maintained separately. Duplicating it here would create two answers to the same question.
Consequence: validate sbom records the validator name, validator version, profile key,
and profile version on every row, so a change in ossbomer output is visible as a change
in evidence rather than as a silent score movement.
10. The core is independent, and every source system is an optional adapter
CRAne is built for an organization starting with nothing. A fresh install has no adapter, no legacy data, and no assumption about which build system, catalog, or directory the organization uses. It is populated through CSV import and manual entry, and it works that way permanently if that is all anyone needs.
A legacy migration adapter is one adapter, not the foundation. The rule that keeps it that way:
- the core never imports anything from
crane.adapters, and the dependency only points inward; - an adapter registers its own namespaced vocabulary at run time through
extension_registration, never through a core migration, so installing or removing an adapter is not a schema change; - nothing named after a source system appears in a core table, column, enum, or check constraint;
- the shared import machinery in
crane.importingknows how to write entities, edges, claims, and findings correctly, and knows nothing about where they came from.
The test that this holds: deleting src/crane/adapters/ entirely must leave a working
product with a passing test suite, minus the adapter’s own tests.
Where a legacy shape turned out to be genuinely general, it went into the core: build workflows depending on other build workflows, subjects attaching to assets, actions depending on actions, and import reconciliation. Every organization with a non-trivial build estate has those. Where a shape was specific, such as rollup relationships or service directory statuses, it stayed in the adapter’s namespace.
11. Two sources asserting the same relationship are two rows and one relationship
Edge tables store assertions. Two systems claiming the same product contains the same component produce two rows, each with its own provenance, confidence, and review status. Collapsing them on write would discard the fact that two systems agree, which is evidence.
Counting those rows would then overstate the portfolio, and every screen would have to remember to deduplicate. So migration 0004 adds a resolved view per edge type: one row per distinct relationship, with the number of assertions behind it, the strongest review status any of them reached, and which kinds of source asserted it. Queries and reports use the views. Provenance stays underneath in the tables.
12. Single private repository for now, boundary respected in layout
The requirements document requires a public core and private tenant adapters in separate repositories. This repository is private, so that separation is not yet enforced by repository boundaries. It is respected in layout instead:
- No confidential organization names, internal system names, or real product names in
src/,migrations/,docs/, or tests. - Private adapters live under
src/crane/adapters/private/, which is gitignored. - Real inventory data lives in
data/, which is gitignored. The database file is never committed.
Splitting into a public core and a private adapter repository stays possible later because the code boundary already exists, and decision 10 is what keeps it real.
That exception has since been closed rather than deferred. An adapter exposes the table and column names of the system it reads, and a verbatim DDL export exposes its whole data model, so adapters, their fixtures, their documentation and any legacy schema export are no longer in this repository at all. They are independent tooling, kept on a developer’s disk as a worked example of how a migration is written, gitignored and never in an image. Scrubbing a schema in place was never an option, because a fixture built from an edited schema proves nothing; removing it entirely is the version that holds. The suite skips the adapter tests when the files are absent, which is the normal case, and a test asserts no tracked file names a source system.
Consequences
- An empty database after
crane initis a valid state with 51 tables, 4 lenses, and a seeded vocabulary. Most tables stay empty until the phase that uses them. - Any leadership number carries a lens and a denominator, which makes some questions harder to answer with a single figure. That is deliberate.
- Because conflicting claims and edges are retained rather than merged, the inventory will show disagreement early. Surfacing that is the point.
Open questions
Tracked in docs/open-questions.md. None of them block the phases already agreed.