✅ fresh
last synced 2026-09-29T21:12:19.681221+00:00 · coverage 92% (
infrastructure)Validation by Beadloom
doc_sync— same source assync-check.
Infrastructure
Domain-agnostic SQLite database layer, health metrics, and git activity analysis.
Note: the cross-domain orchestrators (
reindex,doctor,debt_report,watcher) live in the application layer, not here, so thatinfrastructurenever imports a domain (the DDD Dependency Rule).
Components
Internal building blocks, each with a DOC.md:
- DB — the domain-agnostic SQLite layer (connection, schema, migrations,
meta). - Health — health snapshots + trend computation.
- Git Activity — per-node
git logactivity metrics. - MCP Tools — the canonical MCP tool-name catalog.
- Scan Paths — resolves source scan directories from
config.ymlso domains do not importapplication. - Node Source — whether a file lies under a node's declared source, by path component: the one rule
docs polish, the route attribution of a reindex and git activity call. - Doc Roots — the three documentation spaces (TO-BE / AS-IS / WORKING), their configurable roots and kinds, and which space a document belongs to. Beside
scan-pathsfor the same reason:doc-syncresolves the WORKING freshness exemption without reaching up intoapplication. - Exit Condition — the one definition of what retires a declared exclusion: the date an
until:names, and whether that day has passed. Besidescan-pathsanddoc-rootsfor the same reason, and for a fourth surface:graphreads it for both exemption lists inrules.ymlandonboardingforflow.yml's guard exclusions, so the vocabulary sits below both rather than inside whichever domain wrote it first. - Atomic IO — atomic YAML writes (temp-file +
os.replace) so a crash mid-write never corrupts the source-of-truth graph YAML. - Console Streams — the CLI's own
stdout/stderrkeep the operator's codec but degrade an unencodable glyph to its escape instead of dying on it. - Surface Registry — the port through which the services layer hands its live CLI/MCP surface to the checks below it, so
doctor/docs audit/sync-checkcan compare a documented claim against runtime truth without importing upward.
Specification
Modules
- surface_registry.py — the CLI surface port.
register_cli_group(provider)is called byservices/cli.pyon import;get_cli_group()is read byapplication/doctor.py,doc_sync/audit.pyanddoc_sync/surface.py, so those checks can compare a documented claim against runtime truth without importing upward intoservices(BDL-UX #159). The provider is stored as a callable and invoked per read, and one that raises degrades toNone. Unknown is not zero: the getter returnsNonewhen nothing is registered — distinct from a real but empty surface — so a caller reports "not verified" rather than announcing a count it never looked at. Only the CLI needs a port: the MCP tool list already has a canonical lower-layer source inmcp_tools.MCP_TOOL_CATALOG(pinned equal to the server's registry by a test, and present in every process), so its consumers read that directly. - console_streams.py —
tolerate_unencodable_output()relaxes tobackslashreplacethe two handlers a console stream carries when nobody chose one (strictunder a named locale,surrogateescapeunder C/POSIX) on this process'ssys.stdout/sys.stderr, and never touches their codec: a terminal is the one stream whose encoding genuinely belongs to the operator's locale, so writing UTF-8 into a latin-1 terminal would only move the damage. MEASURED underLC_ALL=en_US.ISO-8859-1before it existed (BDL-061.42):python -m beadloom.ai_agents.ai_techwriter --helpexited 1 on a→in its own help text, and a passingbeadloom guardwrote nothing at all, because the verdict line carries an em dash. Applied at each Click entry object (_root.TolerantOutputGroup, the harness's_TolerantOutputCommand) rather than in a callback, because Click answers--helpwhile parsing. An explicitPYTHONIOENCODING=...:replaceand any stream withoutreconfigureare left alone. MEASURED again for BDL-068beadloom-0mdo.65on Darwin arm64, with the interpreter named in the component's DOC where a code block keepsdocs auditfrom reading it as this project's own version: underLC_ALL=CCPython gives stdoutasciiwithsurrogateescape, which re-encodes lone surrogates and nothing else, sobeadloom docs auditexited 1 after 1321 bytes on the\xb1of its tolerance label while the policy read that default as an operator's decision and stepped aside; the operator's channel is nowPYTHONIOENCODINGrather than the handler's name. - db.py —
open_db()opens a SQLite connection with WAL mode and foreign keys enabled, returning a connection withsqlite3.Rowrow factory.create_schema()creates all tables and applies incremental migrations viaensure_schema_migrations().get_meta()/set_meta()for key-value metadata. ExportsSCHEMA_VERSIONconstant (currently"4"— BDL-038 G7 addedexternalto thenodes/edges/foreign_edgeslifecycleCHECK). Therulestable'srule_typeis free-formTEXT(no CHECK) since BDL-061 S4: the CHECK enumerated the rule vocabulary a second time next to the loader that already validates it, so every new rule type broke every EXISTING database — the loader acceptedscenario_coverageand the insert raisedIntegrityErroron anybeadloom.dbcreated before the release, on the adopter's machine rather than on ours._migrate_drop_rule_type_check()rebuilds a table that still carries the old CHECK (rename-create-copy-drop, idempotent, noSCHEMA_VERSIONgate — the same mechanism as thekindandlifecycleCHECK migrations). - health.py —
take_snapshot()captures current index statistics (node/edge/doc counts, coverage percentage, stale PAIRS, isolated nodes) and persists them to thehealth_snapshotstable. Itsstale_countisrepository.count_stale_pairs(conn).countrather than a query of its own: the same layer, the same population, and one body that names what it counted (BDL-069beadloom-rqma.6).get_latest_snapshots()retrieves history for trend comparison.compute_trend()computes trend indicators (arrows and deltas) between two snapshots. - git_activity.py —
GitActivityfrozen dataclass holds per-node metrics:commits_30d,commits_90d,last_commit_date,top_contributors,activity_level.analyze_git_activity()runsgit log --since=90 days ago, parses output, maps each changed file to the most specific node whose source it lies under (by path component, throughnode_source.NodeSource), and classifies activity (hot: >20 commits/30d, warm: 5-20, cold: 1-4, dormant: 0 commits/90d). - mcp_tools.py — single-source catalog of MCP tool metadata used by AGENTS.md generation.
McpToolDocdescribes one tool;mcp_tool_names()returns the canonical tool-name list (pinned to the live MCP_TOOLSregistry by a drift-guard test) so the documented tool count cannot drift. - node_source.py —
NodeSource(declared)normalises a node's declared source once, removing surrounding whitespace,.segments and a trailing/, so thatNone,''and/declare nothing.holds(file_path)answers whether the path IS the source or continues it past a/. The file path is compared as given. Until BDL-069beadloom-rqma.4this rule had three bodies in three domains, and the route attribution's was a string prefix that gavesrc/ledger/the routes ofsrc/ledger_archive/. It sits at the lowest layer becausegit_activitycalls it, andonboardingreaches it through a statedonboarding-no-direct-infraexemption. It is notrepository.source_covers, which answers ownership. - scan_paths.py —
resolve_scan_paths()readsscan_pathsfrom.beadloom/config.yml, falling back to("src", "lib", "app"). A domain-agnostic config reader at the lowest layer sograph(import resolution) andapplication(reindex) resolve scan directories without a domain importingapplication(closes the BDL-059 S3 layering inversion). - repository.py — the centralized, typed reads over the index, and the single answer to which node owns a file: the most specific node whose
sourcecovers it.covering_prefix()/source_covers()are public because the rule engine's file attribution applies the same rule and the two must not each keep a copy (BDL-061.50).most_specific_owner()is that rule over(ref_id, source)pairs rather than the index, so the test binding can own a path no index holds;get_owning_ref_id()delegates to it (BDL-074 C1).count_test_files_by_placement()reads the test files per placement fromtest_files({}when the table is absent), the one read behind the reindex'sTests:line, thectxbundle'stest_placementsand the debt report's untested count (BDL-074 C2). Since BDL-074 F1count_other_kind_test_files()counts theother_kindfiles by their recorded kind (KIND_ACCEPTANCE,KIND_SELF_CHECK,KIND_UNRECORDEDfor a row without one), andlabel_test_kind()names a kind in a sentence (acceptance step,self-check).get_test_file_bindings(), written forbeadloom mutation --changed-sincein BDL-074 D1, was removed once that command read the test files throughgraph.rules.suite_tables.read_test_files(BDL-074 G1) and nothing called it. The six placement values themselves (PLACEMENT_MIRROR,PLACEMENT_BESIDE_CODE,PLACEMENT_OVERRIDE,PLACEMENT_UNOWNED,PLACEMENT_UNPLACED,PLACEMENT_OTHER_KIND) are defined here since BDL-074 C3, below both of their readers:context_oracle.test_bindingassigns a placement and re-exports the names, andgraph.rules.test_bindingjudges it.PLACEMENT_BESIDE_CODE(BDL-074 G2) is a test inside a node's source, outside every test root. The test layout a reindex read is recorded beside them for the same reason (BDL-074 G2):TEST_LAYOUT_KEY,RecordedTestLayoutandread_test_layout()keep it inmeta, so the builder, the debt report, the rule engine and, sincebeadloom-2mj3.15,beadloom mutation --changed-sincestate their counts against it without importingcontext_oracle. Sincebeadloom-2mj3.15the record also holds each framework group's patterns (RecordedTestLayout.patterns,()for an older record), which the recognition clause names. Sincebeadloom-2mj3.17itsrootsare the roots that exist andabsent_rootsthe ones looked for and not found, so a reader names only the roots a project has.get_owned_code_files()readsfile_index, notcode_symbols, so a module with no top-level symbol still belongs to its node. It also holds the single answer to how many stale things there are and what they are called:count_stale_pairs()returns aStaleCountcarrying the nounpair,stale_node_refs()is the differently named reader of the different population, and every surface that printsN stale pair(s)gets that sentence from here (BDL-069beadloom-rqma.5). See the repository component doc. - doc_roots.py —
resolve_doc_spaces(project_root)reads thedoc_rootsblock from.beadloom/config.ymlinto the three documentation spaces — TO-BE (intent), AS-IS (reality, held against the code bysync-check) and WORKING (ephemeral, exempt from freshness by declaration) — each with its own roots and document kinds, andDocSpaces.space_of(rel_path)answers which space a document belongs to. Kind wins over root, becauseACTIVE.mdlives INSIDE the TO-BE tree and a root-first answer would classify every WORKING document as intent and exempt nothing. Root globs are matched with the same reachPath.globgives them rather than withfnmatch, which lets*cross a separator: a file a root FINDS and the classifier puts in no space is the check disagreeing with itself. A domain-agnostic config reader at the lowest layer, besidescan_paths.py, sodoc_syncresolves the WORKING exemption without importingapplication. Configuration errors are carried, never raised, so one malformed line cannot become a crashing gate that names the wrong file (BDL-061 S5). Among roots the WORKING space is consulted FIRST, because its shipped root list is empty and the AS-IS default is the catch-alldocs/**/*.md: if the catch-all won, a root a project declared WORKING would be silently inert.DocSpaces.project_path(doc_path)translates the docs-dir-relative path async_staterow carries into the one project-relative spelling every root glob is written in, so freshness and the spaces report classify one file alike, andresolve_docs_dir(project_root)is the single reader of thedocs_dirkey three readers held before (beadloom-mr2l.75).doc_roots.to_be.intent_documentsnames the files an epic declares its related nodes in (CONTEXT.md,BRIEF.mdby default): configuration for the same reason the roots are, since a hardcoded pair made an adopter with another convention lose 100% of its epics behind a plausible0 of 0(beadloom-mr2l.73).DocSpaces.classify(project_root)places every document ONE declared root found in ONE bucket, so the populations sum to the number of files the roots matched on any tree, anddocuments_in/working_documentsread off it — the old shape globbed a space's own roots and kept only what the classifier returned to that same space, so a document whose kind sent it elsewhere was in no population, its directory in no epic list, and nothing said so. Kind still wins, and when it overrules a space's own roots the disagreement is itself reported asdocument_outside_declared_root; a space that declares no root (which the shipped WORKING space does) states nothing about where its documents live and contradicts nothing. Among KINDS the WORKING space is consulted first (_KIND_PRECEDENCE, separate fromSPACES, which is the order a report reads best): a kind declared for WORKING changes what a check DOES rather than where it looks, and the three shipped kind lists are disjoint so no shipped classification depends on the order.WorkingExemption.kinds_declared/roots_declaredrecord which halves the project wrote rather than inherited, so liveness is asked of each declared item instead of the declaration as a whole (beadloom-mr2l.77). - exit_condition.py —
exit_condition_deadline(until)returns the calendar date an exit condition names, orNonewhen it names an event;deadline_passed(until, *, today=None)answers whether that day is behind us, andFalsefor an event, because nothing in a date can observe whether the event happened. The deadline names the LAST day the exclusion covers, so an entry whoseuntilis today is still live — reading it one day early would make every entry expire before its own author's deadline. The spelling is pinned to a leadingYYYY-MM-DDby a pattern rather than delegated todate.fromisoformat, which widened in Python 3.11 and would make the sameuntil:enforceable on one supported interpreter and prose on another. Moved here fromgraph/rules/types.pyin BDL-070 B2:onboardingdeclares an exit condition too and was importing a peer domain to read what one is, which was two of the sixteen same-layer crossings that bead triaged.beadloom.graph.rules.exit_condition_deadlinestill answers, by re-export. - atomic_io.py —
write_yaml_atomic(path, data, **dump_kwargs)serializes withyaml.dump(**dump_kwargs), writes to a temp file in the same directory,fsyncs it, then commits withPath.replace(atomic on POSIX). Every graph-YAML writer (graphloader/patcher,serviceslink patcher,onboardingscaffolders) routes through it so a crash mid-write cannot corrupt the source-of-truth graph YAML; dump options pass through verbatim so output bytes are unchanged (BDL-060 S1 / G6).
Database Schema
Stored in .beadloom/beadloom.db (WAL mode):
nodes,edges— architecture graph. Theirkindcolumns are free-formTEXT(no CHECK) so any paradigm's vocabulary (DDDdomain/service, FSDpage/widget/repository, …) is stored and federated faithfully — Beadloom is paradigm-agnostic, not DDD-only (BDL-038 / U1). Both carry alifecyclecolumn (active/planned/deprecated/dead/external, defaultactive; BDL-037 + BDL-038 G7external).edgesalso carries acontract_keycolumn (default'') that is part of its primary key, so multiple AMQP contracts (produces/consumes) on the same(src,dst,kind)pair do not collapse (BDL-037 #102)foreign_edges— cross-repo edges whose at least one endpoint is a@repo:ref_idreference to a node in another repo; kept separate because a foreign endpoint cannot satisfy theedgesFK to local nodes (BDL-037 #100). Carries the samelifecycleCHECK (incl.external)docs,chunks— document indexcode_symbols— code symbol index (includesannotationsJSON andfile_hash)code_imports— resolved import relationshipstest_files,test_imports,test_overrides— the test index (BDL-074 C1).test_filesrecords each test file undertests/with itskindfolder, the node it binds to (ref_id, or NULL), itsplacement(mirror/override/unowned/unplaced/other_kind),test_countandfile_hash.test_importsholds its imports in thecode_importsshape.test_overridesholds thetests:path prefixes nodes declare. Kept apart fromcode_symbols/code_imports/file_indexso tests never become code. Additive,SCHEMA_VERSIONunchanged; the meta keytest_index_versionmarks an index that holds themsync_state— doc-code synchronization (includessymbols_hashfor NODE-level drift detection,file_symbols_hashfor the pair's OWN file — the granularity at which a pair actually makes its claim, so one changed file no longer marks every pair the node owns stale (BDL-UX #182) —doc_hash_at_last_editfor two-phase sync that survives reindex, andbaseline_sourcerecording where the baseline CAME FROM —index_build/carried/attested). ItsstatusCHECK carries four verdicts,ok/stale/missing/unverified: the last two are states in which the checker could not know, and writing them asokis what let a deleted doc and a rebuilt index both read fresh (BDL-UX #174/#175)declared_docs— the DECLARED documentation surface: every doc a node names in itsdocs:list, whether or not the file exists.docsindexes files found on disk, so without this table deleting a declared doc simply removed it from the index and no check could miss it. A cache of the committed graph YAML, rebuilt on reindexfile_index— file hash tracking for incremental reindex (includes__parser_fingerprint__sentinel row)health_snapshots— trend tracking (persists across reindexes)graph_snapshots— point-in-time architecture graph captures (nodes_json, edges_json, symbols_count, label)bundle_cache— L2 persistent bundle cachesearch_index— FTS5 full-text search indexrules— architecture rules fromrules.ymlmeta— index metadata
Parser Fingerprint
incremental_reindex() tracks available tree-sitter parsers via a fingerprint (sorted comma-separated supported_extensions()). Stored as a sentinel row in file_index with path='__parser_fingerprint__'. When the fingerprint changes (e.g. after uv tool install "beadloom[languages]"), a full code reindex is triggered automatically, ensuring new language parsers are used without requiring --full.
API
Module src/beadloom/infrastructure/surface_registry.py:
register_cli_group(provider: Callable[[], Any])— services register the root Click groupget_cli_group()->Any | None— the group, orNonewhen the surface is unknownreset_surface_providers()— clears the provider (tests only)
Module src/beadloom/infrastructure/db.py:
SCHEMA_VERSION— schema version constant (currently"4"; v3 → v4 rebuilt thelifecycleCHECK to admitexternal)open_db(db_path: Path)->sqlite3.Connection— opens DB with WAL mode, foreign keys, andRowfactoryensure_schema_migrations(conn)— applies incremental schema migrations (e.g.symbols_hashcolumn,file_symbols_hashcolumn added LAST because_migrate_sync_status_verdictsrebuildssync_statefrom an explicit column list and would otherwise drop it,doc_hash_at_last_editcolumn for two-phase sync, thelifecyclecolumn onnodes/edges, theedges.contract_keyrebuild, theforeign_edgestable for BDL-037 federation, the BDL-038 / U1 rebuild that drops the legacy DDD-onlykindCHECK onnodes/edgessokindis free-form, and the BDL-038 / G7 rebuild (_migrate_lifecycle_external, v3 → v4) that addsexternalto thenodes/edges/foreign_edgeslifecycleCHECK — all additive + idempotent, guarded on the stored DDL/columns; the rebuild usesPRAGMA legacy_alter_table=ONso renaming a rebuilt table does not dangle dependent FK references)create_schema(conn)— creates all tables and indexes, callsensure_schema_migrations()get_meta(conn, key, default=None)->str | Noneset_meta(conn, key, value)— upserts a key in themetatable
Module src/beadloom/infrastructure/health.py:
HealthSnapshot— frozen dataclass withtaken_at,nodes_count,edges_count,docs_count,coverage_pct,stale_count,isolated_counttake_snapshot(conn)->HealthSnapshot— computes and persists health metricsget_latest_snapshots(conn, n=2)->list[HealthSnapshot]compute_trend(current, previous)->dict[str, str]— computes trend indicators between two snapshots
Module src/beadloom/infrastructure/node_source.py:
NodeSource(declared: str | None)— a node's declared source, normalised onceNodeSource.path->str— the normalised source,''when none is declaredNodeSource.holds(file_path: str)->bool— whether the path is the source or continues it past a/
Module src/beadloom/infrastructure/git_activity.py:
GitActivity— frozen dataclass:commits_30d,commits_90d,last_commit_date,top_contributors,activity_levelanalyze_git_activity(project_root, source_dirs)->dict[str, GitActivity]— parsesgit logfor 90 days, maps files to nodes, classifies activity level (hot/warm/cold/dormant)
The orchestrator modules
reindex,doctor,debt_report, andwatcherwere relocated to the application layer. Their API and tests are documented there.
Testing
Tests: the files under tests/integration/infrastructure/ and tests/unit/infrastructure/, one folder per component node (db/test_db.py, health/test_health.py, git_activity/test_git_activity.py, repository/, scan_paths/ and the rest), each bound to its node by the mirror of its path. The reindex storing git activity is tested in tests/integration/application/reindex/test_reindex_activity.py and the bundle reading it in tests/integration/context_oracle/builder/test_the_context_bundle_carries_git_activity.py. BDL-074 F2 split the two out of one file that tested both, so each is bound to its own node.