atif-sql · Contract map
Section titled “atif-sql · Contract map”What counts as a contract here. This workspace has almost no shared-type imports to trace,
because pyproject.toml:605-614 forbids most of them: the independent members may never import
each other, and atif-analytics may import only atif-models. What crosses a module boundary instead
is a shape agreed by two packages that cannot reference one another’s symbols: a file layout, a
JSON key order, a column projection, a typing.Protocol typed to a document rather than to an
implementation. So a contract in this file is any of:
- a
typing.Protocoldeclared in one package’sdomain/ports.pyand satisfied by an adapter elsewhere (each has a section below or a bullet under “Other contracts”); - a shape declared twice or more in packages that cannot import each other, where the two declarations must agree or a query silently returns wrong rows;
- a build-enforced dependency rule (import-linter), which is a stronger fact than a convention;
- an upstream API this workspace depends on and pins;
- a version pin between workspace members, which the release tooling rewrites and checks on every bump.
Every Protocol here is satisfied STRUCTURALLY, with no import in either direction. That is the
single most important structural fact for reading this file. RealConverter
(packages/atif-cli/src/atif_cli/converter_adapter.py:80) implements ConverterPort
(packages/atif-corpus/src/atif_corpus/domain/ports.py:101) by shape alone; it names the
Protocol only in its docstring, and the Protocol can’t name it back, because atif-corpus may never
import atif-converter. The Protocol name appears at only these import sites in the whole workspace:
packages/atif-corpus/src/atif_corpus/application/materialize.py:172 (the module that CALLS it),
packages/atif-cli/tests/test_converter_adapter.py:17, and
packages/atif-cli/tests/test_codex_cli.py:26 (the tests that bind the two together in an
annotation). So each Protocol row below has a producer of the shape and an implementer that never
imports it; the type system meets the two only at a typed site (an annotated test binding, a
factory’s annotated return, a call into a port-typed parameter), and the answer to “who finds out
if the shape drifts” is one of those sites or nobody. Do not read a Protocol row as a dependency edge: pyproject.toml:605-608 forbids the edge
it would imply.
Every contract below names its producer, its consumers, the verbatim shape, the assumptions
consumers make beyond the shape, and the drift risk. Consumer counts are grep-derived and
confirmed at each import, annotation, or call site: this repo has no code index
(.gitignore:25 lists .codegraph/, and no index exists on disk), so no count here comes from a
symbol graph. These grep hazards shape every count: names collide (DomainError and
EmbeddingProviderMismatch across packages, cached_tokens across coordinate spaces), so every
attribution here is by module path and never by bare name; and outside four atif-cli modules
(listed under the import-linter contracts) every cross-package import under src/ is indented
inside a function body or a TYPE_CHECKING block, so a line-anchored grep of src/ finds almost
nothing.
The sections below aren’t ranked; “Other contracts” at the end collects the smaller ones.
The materialized corpus artifact layout
Section titled “The materialized corpus artifact layout”Producer: packages/atif-corpus/src/atif_corpus/domain/layout.py:18-24
Consumers:
packages/atif-duck/src/atif_duck/infrastructure/registry.py:736-740: builds themeta.jsonglob (and, further down, each session’s stored-file list) and binds it as the one parameter of itsread_jsonreaders, spelling the names from its own constants atpackages/atif-duck/src/atif_duck/domain/artifacts.py:28-36.packages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:166-184: gates onmeta.jsonand opens each stored trajectory with its own DuckDB connection.packages/atif-analytics/src/atif_analytics/infrastructure/corpus_reader.py:72-74: declaresTRAJECTORY_FILENAME/EDGES_FILENAME/META_FILENAMEagain as its own constants.packages/atif-corpus/src/atif_corpus/application/materialize.py:691-731: the writer side, the only place the artifacts are produced.packages/atif-cli/tests/test_artifact_names_twin_pin.py:29: pins the writer’s names against every reader’s copy, and:19pins the.zstsuffix.docs/CONTRACT.md:22-60: the hand-written specification all of them agree to.
Shape:
<corpus_root>/ # default: ~/.atif-sql/corpus/<corpus-slug>/ sessions/<session_id>/ trajectory.json.zst # compact JSON (separators=(',',':')), ATIF-v1.7, one zstd frame loss_report.json # atif_converter LossReport.to_json() edges.jsonl.zst # one line per RAW record: {uuid, parent_uuid, # message_id, type, ts, is_sidechain, # is_compact_summary, source_file, tool_use_ids: [..]} session_events.jsonl.zst # one line per KEPT non-message record; may be empty meta.json # {session_id, source_mtime_ns, source_files: [...], # harbor_version, converter_version, converter_schema, # materialized_at, agent, source_present, # source_removed_at?, source_archive?, # columnar_schema? (old layout)} source/<path>.zst # raw source archive: one zstd file per source file blobs/sha256/<ab>/<sha256>.<ext> # inline attachments, content-addressed, written 0444 watermark.json # {path: mtime_ns} across source corpus empty_sessions.json # sessions whose transcript held nothing to convert sink_pending.json # sessions the SessionSink hasn't taken yetA corpus written by an older build keeps the plain trajectory.json, edges.jsonl, and
session_events.jsonl, plus five per-session parquet files, until atif-sql corpus slim converts
it, and every reader accepts either spelling. The parquet files are a cache of what the views
compute from the trajectory, never a source of truth, and materialize doesn’t write them: the CLI
passes no artifact producer (packages/atif-cli/src/atif_cli/app.py:990-992), and the lake loader
runs atif-duck’s ColumnarArtifactProducer to stage each session it loads
(packages/atif-duck/src/atif_duck/infrastructure/lake.py:561). atif-duck reads a session’s own
parquet files only when meta.columnar_schema matches its version and all five are present and
non-empty (packages/atif-duck/src/atif_duck/infrastructure/columnar.py:116), and falls back to
the trajectory per session otherwise. The names and the schema version live in
packages/atif-duck/src/atif_duck/domain/columnar.py; atif-corpus never learns them, and it merges
whatever keys an ArtifactProducer returns into meta.json.
Assumptions consumers make:
meta.jsonpresent means the session dir is complete. Every reader implements the same torn-set gate independently:packages/atif-duck/src/atif_duck/infrastructure/registry.py:742-748restricts the edges and loss readers to dirs wherev_raw_metahas a row (the trajectory list is built fromv_raw_metatoo);packages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:175-180skips a dir with nometa.json;packages/atif-analytics/src/atif_analytics/infrastructure/corpus_reader.py:178-191does the same and logs a warning. Nothing in the file layout expresses this: it’s a write-ORDER promise made atpackages/atif-corpus/src/atif_corpus/application/materialize.py:635-638.- No reader ever sees a partially written directory, because the writer stages every
per-session artifact under
<corpus_root>/.staging/and swaps the whole dir into place (packages/atif-corpus/src/atif_corpus/application/materialize.py:656and:733), and.stagingis deliberately outsidesessions/so a DuckDB glob can’t reach it (packages/atif-corpus/src/atif_corpus/domain/layout.py:89-94). ASIGKILLor power loss inside the swap leaves the session missing rather than torn. trajectory.jsonis ONE JSON document,edges.jsonlis newline-delimited. atif-duck encodes that split in its reader choice atpackages/atif-duck/src/atif_duck/infrastructure/registry.py:25-28, and in its read bounds: the bound for a document follows the largest file (:246), and the bound foredges.jsonlfollows its longest line (:269). atif-embed re-derives the split atpackages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:99.- A single trajectory document can be enormous. Both DuckDB readers allow a 1 GiB
maximum_object_size, and their measured ceilings disagree: atif-duck caps a bound it sizes from the largest file at 1 GiB, citing 436 MB observed (packages/atif-duck/src/atif_duck/infrastructure/registry.py:217-220), while atif-embed sizes each batch’s bound from its largest document under the same 1 GiB cap, citing 85 MB (packages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:56-59). Same ceiling, two independent justifications. source_mtime_nsand everywatermark.jsonvalue are epoch NANOSECONDS fromos.stat().st_mtime_ns(unit stated in the identifier and again atpackages/atif-corpus/src/atif_corpus/domain/watermark.py:11-12). atif-duck types the columnBIGINTatpackages/atif-duck/src/atif_duck/domain/raw_readers.py:101, which preserves the magnitude but drops the unit from the schema.materialized_atis an ISO-8601 UTC string, not a timestamp. atif-duck projects it asVARCHAR(packages/atif-duck/src/atif_duck/domain/raw_readers.py:105); the CLI supplies it (packages/atif-cli/src/atif_cli/app.py:978).
The hand-written contract and the manifests split the job. Its CLI heading
(docs/CONTRACT.md:300) names atif-cli the package that composes the others. The atif-cli
manifest declares five siblings (packages/atif-cli/pyproject.toml:32-36) and the CLI imports
every one of them; atif-models reaches it through atif-analytics, which imports atif-models
(packages/atif-analytics/pyproject.toml:23) as the import-linter contracts permit.
docs/CONTRACT.md:17-19 leaves VSS/semantic_search and the analytics pipelines to
docs/CONTRACT-V2.md rather than ruling them out; both are shipped commands (the atif-analytics
and atif-embed pins at packages/atif-cli/pyproject.toml:32 and :36, plus the analyze,
embed, and search commands at packages/atif-cli/src/atif_cli/app.py:1725, :1893, and
:2123). Read docs/CONTRACT.md:22-60 as authoritative for the layout (that’s the part the
packages actually implement) and the manifests as authoritative for who imports whom.
Drift risk: a new artifact, a renamed file, or a new meta.json key must be applied in every
place above, and most of them are reader-side, so an addition silently reaches nobody. The file
names and the .zst suffix are pinned across all four packages by
packages/atif-cli/tests/test_artifact_names_twin_pin.py:29 and :19; a new meta.json key has no such
pin. Mitigation: the writer-side constants at
packages/atif-corpus/src/atif_corpus/domain/layout.py:18-24 are the single source of truth on the
write side. Any layout change starts there and then greps the reader modules named above.
The static SQL catalog
Section titled “The static SQL catalog”Producer: packages/atif-duck/src/atif_duck/domain/catalog.py:30 (VIEW_NAMES), with
VIEW_SCHEMA:59, MACRO_NAMES:324, MACRO_SIGNATURES:343, ANALYTICS_VIEW_NAMES:370,
ANALYTICS_VIEW_SCHEMA:384, ANALYTICS_MACRO_SIGNATURES:453, TABLE_MACRO_NAMES:469,
DESCRIPTIONS:487
Consumers:
packages/atif-cli/src/atif_cli/app.py:2464-2516: theschemacommand readsVIEW_SCHEMA,MACRO_SIGNATURES,ANALYTICS_VIEW_SCHEMA, andANALYTICS_MACRO_SIGNATURES, tags each object with itsrequiresvalue, and answers with no DuckDB connection.packages/atif-duck/src/atif_duck/domain/examples.py:35-44: the examples generator imports every catalog and derives one runnable query per object.packages/atif-cli/src/atif_cli/app.py:2423: theexamplescommand callsbuild_examples().packages/atif-duck/tests/test_duck_views.py:51and:67: theDESCRIBE-vs-VIEW_SCHEMAand DDL-vs-MACRO_SIGNATURESdrift tests.packages/atif-duck/tests/test_examples.py:208,:218, and:227:DESCRIPTIONScoverage,ARG_EXEMPLARScoverage, and theAS TABLEset drift test.packages/atif-duck/tests/test_analytics_views.py:260and:242: the analytics-side drift tests.packages/atif-cli/tests/test_app.py:136: asserts the CLI’s JSON payload keys equal the catalog keys.
Shape:
VIEW_NAMES: tuple[str, ...] = ( "sessions", "steps", "messages", ...)
VIEW_SCHEMA: dict[str, tuple[tuple[str, str], ...]] = { "sessions": ( ("session_id", "VARCHAR"), ("agent", "VARCHAR"), ... ), ...}
MACRO_SIGNATURES: dict[str, tuple[str, ...]] = { "ago": ("interval_text",), "model_used": ("sid",), ...}Assumptions consumers make:
- Column ORDER is part of the contract, not just the column set. The drift test asserts tuple
equality against
DESCRIBEoutput, stated atpackages/atif-duck/src/atif_duck/domain/catalog.py:56-58; reordering a view’sSELECTlist fails CI even though every column still exists. DESCRIPTIONScovers every object in every catalog, exactly.packages/atif-duck/tests/test_examples.py:208fails on a missing entry AND on a stale entry keyed to an object that no longer exists, so the dict is a bijection with the union of the name catalogs.- Every macro parameter name has an
ARG_EXEMPLARSliteral, or example generation raises rather than emitting a broken query:packages/atif-duck/src/atif_duck/domain/examples.py:165-171fails loud, andpackages/atif-duck/tests/test_examples.py:218pre-empts it. TABLE_MACRO_NAMESdecides the SQL call shape.packages/atif-duck/src/atif_duck/domain/examples.py:172-175emitsSELECT * FROM name(args)for a member andSELECT name(args)otherwise, so a macro whose DDL gains or losesAS TABLEbreaks every derived example, pinned by the regex test atpackages/atif-duck/tests/test_examples.py:227.- The catalog is answerable without a corpus.
packages/atif-cli/src/atif_cli/app.py:2473-2475states the sub-50 ms, no-DuckDB-bind guarantee theschemacommand rests on; a runtimeDESCRIBEwould violate it. - The raw readers are deliberately NOT in
VIEW_NAMES(packages/atif-duck/src/atif_duck/infrastructure/registry.py:141-142), so a consumer enumeratingVIEW_NAMESdoes not seev_raw_trajectoriesand friends.
Drift risk: adding a view or macro without a DESCRIPTIONS entry, an ARG_EXEMPLARS entry for
each new parameter name, or TABLE_MACRO_NAMES membership fails CI loudly: the risk is inverted
here, and the real exposure is a type that only the fixture corpus produces. Mitigation:
VIEW_SCHEMA["message_embeddings"] at packages/atif-duck/src/atif_duck/domain/catalog.py:281-287 hardcodes FLOAT[1024], so run the
drift test against a store built at a non-default output_dimension before changing that setting.
The import-linter architecture contracts
Section titled “The import-linter architecture contracts”Producer: pyproject.toml:553-614
Consumers:
mise.toml:250:lint:importsis one of themise run checkgates, so the build is the consumer.packages/atif-corpus/src/atif_corpus/domain/ports.py:5-7: the ports module docstring cites the independence contract as the reason its Protocols are typed to the contract’s shapes.packages/atif-embed/src/atif_embed/domain/ports.py:11-15: cites it as the reasonTextRowsPortexists.packages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:5-10: cites it as the reason atif-embed carries its own corpus reader.packages/atif-duck/src/atif_duck/domain/embedding_guard.py:5-9: cites it as the reason the guard is duplicated.packages/atif-duck/src/atif_duck/infrastructure/analytics.py:16-18: cites it as the reason the analytics artifact names are pinned by hand.packages/atif-analytics/src/atif_analytics/domain/layout.py:11-13: cites it as the reason its layout is computed in one place.
Shape:
[tool.importlinter]root_packages = ["atif_converter", "atif_corpus", "atif_duck", "atif_models", "atif_analytics", "atif_embed", "atif_cli"]
[[tool.importlinter.contracts]]name = "converter / corpus / duck / models / embed are mutually independent (only atif-cli composes; atif-analytics may import atif-models only)"type = "independence"modules = ["atif_converter", "atif_corpus", "atif_duck", "atif_models", "atif_embed"]
[[tool.importlinter.contracts]]name = "atif-analytics imports only atif-models among workspace packages"type = "forbidden"source_modules = ["atif_analytics"]forbidden_modules = ["atif_converter", "atif_corpus", "atif_duck", "atif_embed", "atif_cli"]Assumptions consumers make:
- atif-cli is the only composition root, and it is absent from both the
independencemodule list (pyproject.toml:608) and theforbiddensource list (pyproject.toml:613). The one module that imports two independent packages at module scope ispackages/atif-cli/src/atif_cli/converter_adapter.py:64-74(app.pyimports them inside command bodies), and its docstring names that privilege explicitly atpackages/atif-cli/src/atif_cli/converter_adapter.py:5-9. - atif-analytics is absent from the independence list on purpose so it can compose atif-models,
with the
forbiddencontract pinning its other edges shut; the reasoning is inline atpyproject.toml:600-604. Verified:grep -rn 'atif_duck' packages/atif-analytics/finds two prose mentions of the authorship twin and no import, so the CONTRACT-V2-era design of reading atif-duck’s views is not what the code does. - atif-duck declares no
layerscontract. Five members do (pyproject.toml:556-598), atif-models among them with justinfrastructure > domain; atif-duck has the same two layers,domain/andinfrastructure/, and no contract orders them, so itsdomain/staying free of infrastructure imports is a convention. - Every layered member’s
domain/is the innermost layer, which is why every port Protocol lives indomain/ports.pyand none in anapplication/ports.py: no such file exists in this workspace. - The comment at
pyproject.toml:600-604grants a permission that atif-cli doesn’t exercise. It leaves atif-cli and atif-analytics free to import atif-models, and atif-cli neither declares atif-models inpackages/atif-cli/pyproject.toml:32-36nor imports it fromsrc/:grep -rn 'atif_models' packages/atif-cli/finds one test import (packages/atif-cli/tests/test_analyze_lake.py:44). The live atif-models edge is atif-analytics’ alone, its import sites led bypackages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:32-33. - Every cross-package import under
src/is INDENTED (inside a function body or aTYPE_CHECKINGblock) becausePLC0415is ignored workspace-wide to satisfy the lean-import test (pyproject.toml:225). The exceptions are module-scope imports in atif-cli modules that are themselves only ever imported inside a command body:packages/atif-cli/src/atif_cli/converter_adapter.py:64-74,packages/atif-cli/src/atif_cli/duck_errors.py:27,packages/atif-cli/src/atif_cli/lake_sessions.py:43-51, andpackages/atif-cli/src/atif_cli/embed_lake.py:19. A line-anchored grep ofsrc/for another package’s^from atif_finds only those four; the atif-cli and atif-analytics test suites do import siblings at module scope. That’s why every count in this file comes from an unanchored grep confirmed at the site.
Drift risk: a new workspace member that is not added to root_packages (pyproject.toml:554) is silently
unchecked: import-linter reports every contract kept while the new package imports whatever it
likes. Mitigation: adding a packages/* member means adding it to root_packages and giving it
either a layers contract or a place in the independence list in the same commit.
LlmStructuredProvider: the structured-output port
Section titled “LlmStructuredProvider: the structured-output port”Producer: packages/atif-models/src/atif_models/domain/ports.py:131
Consumers:
packages/atif-analytics/src/atif_analytics/application/use_cases/classify.py:69(annotated atpackages/atif-analytics/src/atif_analytics/application/use_cases/classify.py:102,packages/atif-analytics/src/atif_analytics/application/use_cases/classify.py:381)packages/atif-analytics/src/atif_analytics/application/use_cases/conflicts.py:83(packages/atif-analytics/src/atif_analytics/application/use_cases/conflicts.py:120,packages/atif-analytics/src/atif_analytics/application/use_cases/conflicts.py:361)packages/atif-analytics/src/atif_analytics/application/use_cases/friction.py:81(packages/atif-analytics/src/atif_analytics/application/use_cases/friction.py:214,packages/atif-analytics/src/atif_analytics/application/use_cases/friction.py:508)packages/atif-analytics/src/atif_analytics/application/use_cases/perceived.py:94(packages/atif-analytics/src/atif_analytics/application/use_cases/perceived.py:145,packages/atif-analytics/src/atif_analytics/application/use_cases/perceived.py:368)packages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:32: the provider factory and the usage/budget plumbing.packages/atif-models/src/atif_models/infrastructure/providers.py:20:build_structured_provider, which picks the adapter byspec.familyand returns it typed as the port (:20-34).packages/atif-models/src/atif_models/infrastructure/openai_bedrock.py:81:OpenAiBedrockProvider, the openai-family adapter (the default family).packages/atif-models/src/atif_models/infrastructure/anthropic_bedrock.py:130:AnthropicBedrockProvider, the anthropic-family adapter.packages/atif-models/tests/test_models_ports.py:29-38: bindsOpenAiBedrockProviderto the port by annotation,isinstance, and a member sweep.packages/atif-analytics/tests/analytics_fixtures.py:300: the deterministic double.
Shape:
@runtime_checkableclass LlmStructuredProvider(Protocol): """Port: one structured-output call. One adapter per backend.
The seam is deliberately narrow — everything provider-specific is fixed at adapter construction. """
async def classify_structured( self, *, system: str, prompt: str, schema: type[SchemaT] ) -> SchemaT: ...Assumptions consumers make:
-
The error taxonomy is binary and load-bearing.
RefusalErroris terminal andProviderUnavailableis retryable (packages/atif-models/src/atif_models/domain/ports.py:43-58), and the pipelines route on exactly that split: a third error type would fall through to neither the refusal sidecar nor the retry queue. Each adapter maps its own stop signal onto it: acontent_filterorrefusalfinish reason and Claude’sstop_reason: refusalraiseRefusalError(packages/atif-models/src/atif_models/infrastructure/openai_bedrock.py:219-225,packages/atif-models/src/atif_models/infrastructure/anthropic_bedrock.py:253-257), and a truncated response is retried once at oneEFFORT_DEGRADEstep lower before it raisesProviderUnavailable(packages/atif-models/src/atif_models/infrastructure/bedrock_common.py:44-63). -
The Protocol says nothing about token accounting, yet every consumer reads it off the concrete provider.
packages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:75andpackages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:132both reach forgetattr(provider, "usage", None)becauseusageis not on the port; a conforming adapter without that attribute silently reports zero spend and theRunBudgetceiling never trips. -
CallUsagecounts are PER CALL, and some of them are subsets of others.reasoning_tokensis a subset ofoutput_tokensandcached_tokensa subset ofinput_tokens(packages/atif-models/src/atif_models/domain/ports.py:65-71), soestimate_costatpackages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:78-84pricesinput_tokensandoutput_tokensand never addsreasoning_tokensorcached_tokens, which would double-charge.long_input_tokens/long_output_tokensare subsets too: the accumulator adds a long-context call’s tokens to both the totals and the long split (packages/atif-models/src/atif_models/domain/ports.py:107-109), andestimate_costbills the split at the long rates in place of the standard ones (packages/atif-models/src/atif_models/domain/registry.py:202-210). -
input_tokensis the TOTAL billed input for both families. The OpenAI adapter mapsprompt_tokensstraight across (packages/atif-models/src/atif_models/infrastructure/openai_bedrock.py:74), while the Anthropicinput_tokensfield leaves out cache reads and writes, so the Anthropic adapter adds the three together (packages/atif-models/src/atif_models/infrastructure/anthropic_bedrock.py:84-99). An adapter that copied the provider’s field as-is would under-report Claude spend. -
UsageAccumulatormust be thread-safe, not task-safe. The lock is athreading.Lock(packages/atif-models/src/atif_models/domain/ports.py:90) because adapters dispatch blockinginvoke_modelthroughanyio.to_thread, reasoning stated atpackages/atif-models/src/atif_models/domain/ports.py:16-22. -
The budget is a stop-dispatch trigger, not a cap.
packages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:108-115documents the overshoot bound asmax_cost_usdplus at mostBUDGET_CHECK_BATCHunits in flight; a consumer treatingmax_cost_usdas a hard ceiling is wrong. -
pricing_in/pricing_outonModelSpecare USD per 1,000,000 tokens, stated atpackages/atif-models/src/atif_models/domain/registry.py:53-54, andNonewhen unknown, whichestimate_cost(packages/atif-models/src/atif_models/domain/registry.py:180) turns into aNonecost rather than a zero. -
A call over
long_context_thresholdbills WHOLE at the long rates (packages/atif-models/src/atif_models/domain/registry.py:60-64). Each adapter marks such a call throughis_long_contextbefore it adds the usage (packages/atif-models/src/atif_models/infrastructure/openai_bedrock.py:209-212,packages/atif-models/src/atif_models/infrastructure/anthropic_bedrock.py:248-251), so an adapter that skipped the mark would price a long call at the standard rate. -
Neither adapter names the port outside a docstring. Both import
CallUsage,ProviderUnavailable,RefusalError,SchemaT, andUsageAccumulatorfrom the port module and not the Protocol (packages/atif-models/src/atif_models/infrastructure/openai_bedrock.py:41-47,packages/atif-models/src/atif_models/infrastructure/anthropic_bedrock.py:61-67). The static link is the factory’s annotated return:packages/atif-models/src/atif_models/infrastructure/providers.py:26declaresLlmStructuredProviderand:31and:34return the two concrete adapters, so ty and pyright check both at those return statements;packages/atif-analytics/src/atif_analytics/application/use_cases/_shared.py:36-38passes the port type on astuple[LlmStructuredProvider, ModelSpec]. OnlyOpenAiBedrockProviderhas a conformance test (packages/atif-models/tests/test_models_ports.py:29-38);packages/atif-models/tests/test_anthropic_provider.py:298-302checks just that the factory returns each concrete class. -
build_structured_providersends every family that isn’tanthropicto the OpenAI adapter (packages/atif-models/src/atif_models/infrastructure/providers.py:28-34). What keeps a registry family with no adapter from reaching it isRUNNABLE_FAMILIES, which settings load checks before any call (packages/atif-models/src/atif_models/infrastructure/settings.py:25and:41-54).
Drift risk: the port is @runtime_checkable, so an isinstance check passes on method names
alone and would accept an adapter with the wrong parameter kinds (positional instead of
keyword-only), and the only isinstance against it is the conformance test’s
(packages/atif-models/tests/test_models_ports.py:33); no production code guards on it.
Mitigation: the keyword-only signature at
packages/atif-models/src/atif_models/domain/ports.py:138-140 is the contract, and the factory’s
annotated return at packages/atif-models/src/atif_models/infrastructure/providers.py:26 is where
a mismatched adapter fails the typecheck gate.
The edges.jsonl line shape
Section titled “The edges.jsonl line shape”Producer: packages/atif-converter/src/atif_converter/domain/edges.py:22-32
Consumers:
packages/atif-duck/src/atif_duck/domain/raw_readers.py:62-72:EDGE_COLUMNS(the registry’s_EDGE_COLUMNS), the same keys in the same order with DuckDB types.packages/atif-analytics/src/atif_analytics/infrastructure/corpus_reader.py:249-268: reads theuuidfield for the conflicts pipeline’s returned-uuid validity guard.packages/atif-corpus/src/atif_corpus/application/materialize.py:695-698: writes the lines and owns line termination.packages/atif-duck/src/atif_duck/domain/catalog.py:94: themessagesview publishes this surface, every key buttool_use_ids, plussession_id.
Shape:
#: Stable key order for one edges.jsonl line (contract-fixed shape).EDGE_FIELDS: tuple[str, ...] = ( "uuid", "parent_uuid", "message_id", "type", "ts", "is_sidechain", "is_compact_summary", "source_file", "tool_use_ids",)Assumptions consumers make:
- The producer emits lines WITHOUT trailing newlines and the writer adds them. Stated on the
port’s
edges_linesfield atpackages/atif-corpus/src/atif_corpus/domain/ports.py:74-77and honoured atpackages/atif-corpus/src/atif_corpus/application/materialize.py:695-698; a producer that terminated its own lines would double them. parent_uuidis a nullable string, and atif-duck declares itVARCHARoutright rather than letting JSON union inference decide; the reason is recorded atpackages/atif-duck/src/atif_duck/domain/raw_readers.py:57-61.tool_use_idsmeans two different things by record type: tool_use block ids for assistant records, thetool_use_idof tool_result blocks for user records (packages/atif-converter/src/atif_converter/domain/edges.py:11-13). One field, two semantics, discriminated by the siblingtypecolumn.edges.jsonlis the only source of raw-record identity. The trajectory cannot supply uuids (packages/atif-duck/src/atif_duck/infrastructure/registry.py:70-72, fidelity gap 7UUID_NOT_PRESERVED), which is why themessagesview is reconstructed from edges rather than from steps.records_totalin the loss report equals this file’s line count. Stated atpackages/atif-converter/src/atif_converter/domain/fidelity.py:123-127and asserted bypackages/atif-converter/tests/test_snapshot_and_drift.py:133. Both derive from the same raw census, so a consumer may cross-check one against the other.
Drift risk: the keys are declared twice in packages that cannot import each other, and a
new key added on the producer side is simply invisible to EDGE_COLUMNS: a projection reader
drops unknown keys without erroring. Mitigation: EDGE_FIELDS is a single tuple; changing it means
editing packages/atif-duck/src/atif_duck/domain/raw_readers.py:62-72 in the same commit.
EmbeddingProvider, VectorStorePort, TextRowsPort
Section titled “EmbeddingProvider, VectorStorePort, TextRowsPort”Producer: packages/atif-embed/src/atif_embed/domain/ports.py:37, packages/atif-embed/src/atif_embed/domain/ports.py:65, packages/atif-embed/src/atif_embed/domain/ports.py:89
Consumers:
packages/atif-embed/src/atif_embed/application/embed.py:37: imports every one of them; annotated atpackages/atif-embed/src/atif_embed/application/embed.py:48-49andpackages/atif-embed/src/atif_embed/application/embed.py:67-69.packages/atif-embed/src/atif_embed/infrastructure/cohere_bedrock.py:285: the CohereEmbeddingProvideradapter (the default);packages/atif-embed/src/atif_embed/infrastructure/gemma_local.py:193is the local EmbeddingGemma one, andpackages/atif-embed/src/atif_embed/infrastructure/providers.py:19picks between them.packages/atif-embed/src/atif_embed/infrastructure/lance_store.py:597: theVectorStorePortadapter.packages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:229: theTextRowsPortadapter over the corpus files;packages/atif-embed/src/atif_embed/infrastructure/lake_text_rows.py:89wraps it with a lake-backed reader and a watermark.packages/atif-embed/tests/embed_fixtures.py:203: the deterministicEmbeddingProviderdouble.packages/atif-embed/tests/test_embed_use_case.py:501: a recordingTextRowsPortdouble.
Shape:
class EmbeddingProvider(Protocol): @property def model_id(self) -> str: ... @property def dimension(self) -> int: ... async def embed_documents(self, texts: list[str]) -> list[list[float] | None]: ... def embed_query(self, text: str) -> list[float]: ...
class VectorStorePort(Protocol): def table_identity(self) -> tuple[str, int] | None: ... def get_embedded_hashes(self) -> dict[str, str]: ... def delete_uuids(self, uuids: Iterable[str]) -> int: ... def add_chunk(self, df: pl.DataFrame) -> None: ... def optimize(self) -> None: ...
class TextRowsPort(Protocol): def iter_unembedded( self, corpus_root: Path, *, embedded: dict[str, str] | None = None, limit: int | None = None, ) -> Iterator[PendingText]: ... @property def discovery(self) -> str: ... def commit(self, *, stored_rows: int) -> None: ...Assumptions consumers make:
-
embed_documentsreturns one slot per input text in input order, and the caller enforces it.packages/atif-embed/src/atif_embed/application/embed.py:207zips withstrict=True, so a provider returning a different length raisesValueErrorinstead of misattributing vectors to texts. -
A
Noneslot means “not embedded this run”, not “failed the run”. The port documents bounded loss atpackages/atif-embed/src/atif_embed/domain/ports.py:53-56;packages/atif-embed/src/atif_embed/application/embed.py:205-211filters theNones, counts them as skipped, and leaves those uuids for the next pass. -
The store’s
(model, dim)stamp is checked BEFORE any append,packages/atif-embed/src/atif_embed/application/embed.py:182-190, using the identitytable_identity()returns;Nonethere means empty and any provider may claim it. -
add_chunkaccepts a polars frame with a fixed-sizepl.Array, not apl.List. The schema is built atpackages/atif-embed/src/atif_embed/application/embed.py:231-250and the reason is stated atpackages/atif-embed/src/atif_embed/application/embed.py:221-224: a variable-size list is rejected by Lance for indexing. -
iter_unembedded’s laziness bounds only the CALLER’s residency. The port says so explicitly atpackages/atif-embed/src/atif_embed/domain/ports.py:113-116: peak resident text inside an implementation is each adapter’s own problem, andpackages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:81-87establishes it by batching on BYTES (4 MiB) rather than on file count. -
Staleness is decided by hash, not by uuid presence. A uuid under a different
text_hashis yielded withreplaces_existing=True(packages/atif-embed/src/atif_embed/domain/ports.py:108-111), and the caller must delete before appending or the uuid fans out to two vectors (packages/atif-embed/src/atif_embed/application/embed.py:219-226). -
The CLI injects only the text-rows port.
packages/atif-cli/src/atif_cli/app.py:2032-2038callsrun_backfillwithcorpus_root,settings,text_rows(DuckDbTextRows, wrapped inLakeTextRowsunless--no-lakeis passed),limit, anddry_run; the use case constructs the default store and embedder itself under deferred imports (packages/atif-embed/src/atif_embed/application/embed.py:114-117,packages/atif-embed/src/atif_embed/application/embed.py:173-176), and the embedder only after the dry-run return, so a dry run never loads boto3 or torch. -
Each file-path adapter is bound to its Protocol by a test, as
RealConverteris toConverterPort:packages/atif-embed/tests/test_embed_ports.py:49,:55, and:61assignCohereBedrockEmbedder,LanceVectorStore, andDuckDbTextRowsto port-annotated names and sweep the Protocol’s members, andpackages/atif-embed/tests/test_gemma_local.py:146does the same for the Gemma embedder. Insiderun_backfilleach default is assigned to a parameter already annotated with the port (packages/atif-embed/src/atif_embed/application/embed.py:116-124against the annotations atpackages/atif-embed/src/atif_embed/application/embed.py:67-69), andbuild_embedderreturns both adapters under anEmbeddingProviderannotation; ty and pyright check those too.LakeTextRowshas no member sweep: its one static link is a test passing it asrun_backfill’stext_rowsargument (packages/atif-embed/tests/test_lake_text_rows.py:265), which pyright checks and ty doesn’t, because ty ignores argument types underpackages/*/tests/(pyproject.toml:453-460). The CLI types its owntext_rowsasAny.
Drift risk: dimension is read once per run and stamped on every row (packages/atif-embed/src/atif_embed/application/embed.py:178), so
a provider whose width depends on the input rather than on configuration would write a store whose
rows disagree with their own dim column. Mitigation: dimension is a property fixed at adapter
construction from settings: packages/atif-embed/src/atif_embed/infrastructure/cohere_bedrock.py:305-308 reads it from
packages/atif-embed/src/atif_embed/infrastructure/settings.py:110-114, never from a response.
ConverterPort and ConversionOutput
Section titled “ConverterPort and ConversionOutput”Producer: packages/atif-corpus/src/atif_corpus/domain/ports.py:101 (ConverterPort) and packages/atif-corpus/src/atif_corpus/domain/ports.py:66
(ConversionOutput)
Consumers:
packages/atif-corpus/src/atif_corpus/application/materialize.py:172: imports the port; annotated atpackages/atif-corpus/src/atif_corpus/application/materialize.py:615andpackages/atif-corpus/src/atif_corpus/application/materialize.py:1501.packages/atif-cli/src/atif_cli/converter_adapter.py:80-148:RealConverter, the production adapter and the only module importing both atif-converter and atif-corpus at module scope.packages/atif-corpus/src/atif_corpus/infrastructure/fake_converter.py:72: the test adapter that ships insrc/, not intests/.packages/atif-cli/tests/test_converter_adapter.py:17: asserts the mapping and bindsRealConverterto the port annotation atpackages/atif-cli/tests/test_converter_adapter.py:95;packages/atif-cli/tests/test_codex_cli.py:156binds the Codex-configured adapter the same way.packages/atif-cli/src/atif_cli/app.py:976: wiresRealConverter(agent=settings.agent)into the use case.
Shape:
@dataclass(frozen=True, slots=True)class ConversionOutput: trajectory_dict: dict[str, Any] loss_report_dict: dict[str, Any] edges_lines: list[str] events_lines: list[str] = field(default_factory=list) source_archive: tuple[ArchivedSource, ...] = () blobs: tuple[BlobOutput, ...] = ()
class ConverterPort(Protocol): """Anything that can turn one session JSONL into corpus artifacts.
Implementations may raise any exception: the materialize use case records the failure against the session and continues — one broken transcript must never abort a corpus sync. :class:`EmptySourceError` is the one exception with its own meaning: the session is empty, not broken. """
def convert(self, session_jsonl: Path, *, archive_dir: Path | None = None) -> ConversionOutput: """Convert one session (main JSONL + its side-files) to artifacts.""" ...Assumptions consumers make:
RealConverternever imports the Protocol it implements. It names it only in its docstring and imports the return-value data classes and the empty-source error (packages/atif-cli/src/atif_cli/converter_adapter.py:69-74), so nothing in either package links the class to the port. The static links are the annotated assignmentsconverter: ConverterPort = RealConverter()atpackages/atif-cli/tests/test_converter_adapter.py:95, whose own comment (packages/atif-cli/tests/test_converter_adapter.py:93-94) states that the assignment is what ty verifies, and its Codex twin atpackages/atif-cli/tests/test_codex_cli.py:156, plus the CLI’s call atpackages/atif-cli/src/atif_cli/app.py:976, which hands the adapter to materialize’sconverter: ConverterPortparameter. A signature change on either side fails the typecheck gate at those sites, not at materialize time.- The port is typed to
docs/CONTRACT.md’s artifact shapes, not to converter internals: loosely typeddict[str, Any]/list[str]fields instead of the converter’s ownConversionResult. The docstring atpackages/atif-corpus/src/atif_corpus/domain/ports.py:3-10names the independence contract as the reason, so the weak typing is the contract, not an omission. - Exceptions cross this port by design.
packages/atif-corpus/src/atif_corpus/domain/ports.py:104-107says an implementation may raise anything and the use case records the failure and continues;EmptySourceErroralone means the session is empty, not broken.RealConverterrelies on that: it raisesTrajectoryValidationErrorfor an invalid trajectory rather than materializing it (packages/atif-cli/src/atif_cli/converter_adapter.py:129-130, reasoning atpackages/atif-cli/src/atif_cli/converter_adapter.py:49-53). trajectory_dictis the ENRICHED trajectory and is NOT yet compact-serialized. The writer ownsseparators=(",", ":"); the mapping decision is stated atpackages/atif-cli/src/atif_cli/converter_adapter.py:30-31and the writer applies it atpackages/atif-corpus/src/atif_corpus/application/materialize.py:691-693.loss_report_dictisLossReport.to_json()-shaped with enum members flattened to strings (packages/atif-cli/src/atif_cli/converter_adapter.py:32-33), which is what makes it readable by atif-duck’sLOSS_REPORT_COLUMNSprojection atpackages/atif-duck/src/atif_duck/domain/raw_readers.py:77-90.- Importing the adapter loads atif-converter and its ATIF pydantic models.
packages/atif-cli/src/atif_cli/converter_adapter.py:55-57forbids importing it atatif_cli.appmodule scope, andpackages/atif-cli/src/atif_cli/app.py:940obeys by importing inside the command body, enforced by the fresh-interpreter lean-import test named atpyproject.toml:225.
Drift risk: the dict[str, Any] fields mean a converter that renames a trajectory key
type-checks perfectly and produces artifacts atif-duck’s explicit projections silently null out;
and because the implementer never names the port, a convert signature change on either side is
caught only where a typed site meets the adapter. Mitigation:
packages/atif-cli/tests/test_converter_adapter.py:92-96 is the dedicated assertion (treat it as part of
the contract, not as a redundant smoke test), and
packages/atif-cli/tests/test_converter_adapter.py:44 pins the field mapping while atif-duck’s
DESCRIBE drift test catches a projection that stops matching.
Token semantics and the pricing table
Section titled “Token semantics and the pricing table”Producer: packages/atif-duck/src/atif_duck/infrastructure/registry.py:73-76 (the semantics)
and packages/atif-duck/src/atif_duck/domain/catalog.py:612 (DEFAULT_PRICING)
Consumers:
packages/atif-duck/src/atif_duck/infrastructure/registry.py:2025-2045: thecost_estimatemacro, which consumes both.packages/atif-duck/src/atif_duck/infrastructure/registry.py:1981:register_macrosresolves the pricing override againstDEFAULT_PRICING.packages/atif-duck/src/atif_duck/domain/catalog.py:85-88: thestepsview schema publishesprompt_tokens,completion_tokens,cached_tokens,cache_creationto every SQL consumer.packages/atif-duck/src/atif_duck/domain/catalog.py:557-563: the agent-facingcost_estimatedescription carries the trust condition.
Shape:
# Model pricing per 1M tokens (in_rate, out_rate) at public list rates from# Anthropic's published pricing page.DEFAULT_PRICING: dict[str, tuple[float, float]] = { "claude-fable-5": (10.0, 50.0), ... "claude-opus-5": (5.0, 25.0), ... "claude-sonnet-5": (2.0, 10.0), ... "claude-haiku-4-5": (1.0, 5.0),}Assumptions consumers make:
prompt_tokensis a CUMULATIVE TOTAL, per step: non-cached + cache_read + cache_creation. Stated atpackages/atif-duck/src/atif_duck/infrastructure/registry.py:73-76and read frommetrics.prompt_tokensatpackages/atif-duck/src/atif_duck/infrastructure/projections.py:130. The identifier does not say so, so a consumer summingprompt_tokens + cached_tokensdouble-counts every cache read.cached_tokensis the cache-READ subset ofprompt_tokens, not an additional quantity (packages/atif-duck/src/atif_duck/infrastructure/projections.py:96-97).cost_estimatetherefore pricesprompt_tokens - cached_tokensand documents that identity atpackages/atif-duck/src/atif_duck/infrastructure/registry.py:2004-2007; cache reads are charged nothing.in_rate/out_rateare USD per 1,000,000 tokens. The unit appears in neither name: only the comment atpackages/atif-duck/src/atif_duck/domain/catalog.py:602and the/ 1e6atpackages/atif-duck/src/atif_duck/infrastructure/registry.py:2032carry it.est_cost_usdis USD and covers the PRICED steps only. It is meaningful only whenunpriced_steps = 0, stated in each of these places:packages/atif-duck/src/atif_duck/infrastructure/registry.py:2014-2019,packages/atif-duck/src/atif_duck/domain/catalog.py:557-563, and theLEFT JOINshape itself atpackages/atif-duck/src/atif_duck/infrastructure/registry.py:2040.unpriced_stepscounts only steps with a non-NULLmodel_name, because user steps carry no model and cost nothing (packages/atif-duck/src/atif_duck/infrastructure/registry.py:2021-2024). Counting them would put every conversation above zero and mask real pricing gaps.- Cache write and read multipliers are NOT modelled.
packages/atif-duck/src/atif_duck/domain/catalog.py:609-611states that the 1.25x / 2x / 0.1x tiers are out of scope, soest_cost_usdunder-reports a cache-heavy session. - A step’s
model_namematches a pricing row by exact name once a trailing-YYYYMMDDis stripped (packages/atif-duck/src/atif_duck/infrastructure/registry.py:2041), soclaude-haiku-4-5-20251001prices asclaude-haiku-4-5.
Drift risk: cached_tokens exists in two coordinate spaces under one name: atif-duck’s
per-step cache-read count (packages/atif-duck/src/atif_duck/infrastructure/projections.py:132) and atif-models’ per-call
CallUsage.cached_tokens (packages/atif-models/src/atif_models/domain/ports.py:77). Neither
name states its scope, so a cross-plane join or a copied formula silently mixes them. Mitigation:
attribute the field by module path, and read the producer docstring
(packages/atif-duck/src/atif_duck/infrastructure/registry.py:73-76 or
packages/atif-models/src/atif_models/domain/ports.py:65-71) before using either in arithmetic.
The EmbeddingProviderMismatch guard, declared twice
Section titled “The EmbeddingProviderMismatch guard, declared twice”Producer: two coordinate declarations, coupled by contract rather than by import:
packages/atif-embed/src/atif_embed/domain/errors.py:29 with the rule at
packages/atif-embed/src/atif_embed/domain/embedding_guard.py:38, and
packages/atif-duck/src/atif_duck/domain/embedding_guard.py:33 with the rule at packages/atif-duck/src/atif_duck/domain/embedding_guard.py:51
Consumers:
packages/atif-embed/src/atif_embed/application/embed.py:182-190: the WRITE path callsensure_store_matchesbefore appending.packages/atif-duck/src/atif_duck/infrastructure/registry.py:1762-1777: the READ/bind path calls it before bindingmessage_embeddings(guard-before-bind).packages/atif-cli/src/atif_cli/duck_errors.py:27-32: puts atif-duck’s copy inREGISTRATION_ERRORSand maps it to exit 65 atpackages/atif-cli/src/atif_cli/duck_errors.py:75-82.packages/atif-embed/tests/test_guard_twin_pin.py:85: reads both twin modules as source text and requires the recovery hint to appear after theraisekeyword.
Shape:
def ensure_store_matches( *, stored_model: str | None, stored_dim: int | None, expected_model: str, expected_dim: int | None,) -> None: if stored_model is None or stored_dim is None: return model_ok = stored_model == expected_model dim_ok = expected_dim is None or stored_dim == expected_dim if model_ok and dim_ok: return raise EmbeddingProviderMismatch(...)Assumptions consumers make:
- The two copies must stay behaviourally identical, including the message. Both docstrings say
so (
packages/atif-duck/src/atif_duck/domain/embedding_guard.py:5-11andpackages/atif-embed/src/atif_embed/domain/embedding_guard.py:15-18), and the pin test is what makes it more than a comment. - The message text must be constructed INSIDE the
raise. Hoisting it to a local satisfies ruff’sEM102/TRY003and defeats the twin pin, so both rules are suppressed on purpose atpackages/atif-duck/src/atif_duck/domain/embedding_guard.py:72-79. - A
Noneon either stored value means “empty store, any provider may claim it”, not “unknown, be careful” (packages/atif-duck/src/atif_duck/domain/embedding_guard.py:60-61). expected_dim=Nonetrustsmodel_idalone, and dim is checked only because Cohere’s single model id can emit different Matryoshka widths (packages/atif-duck/src/atif_duck/domain/embedding_guard.py:62-64).- The two classes have different base classes and that matters at the CLI. atif-duck’s derives
from
Exception(packages/atif-duck/src/atif_duck/domain/embedding_guard.py:33) while atif-embed’s derives from that package’sDomainError(packages/atif-embed/src/atif_embed/domain/errors.py:29), so a bareexcept duckdb.Erroron the registration path would let atif-duck’s escape as exit 1 (the reasonREGISTRATION_ERRORSwidens the caught set, stated atpackages/atif-cli/src/atif_cli/duck_errors.py:15-19). - The recovery hint must not name a fixed home directory. The store lives at
<corpus_root>/embeddings_lanceby default, and both copies record that naming the wrong path makes the operator delete nothing (packages/atif-duck/src/atif_duck/domain/embedding_guard.py:21-24andpackages/atif-embed/src/atif_embed/domain/embedding_guard.py:25-29).
Drift risk: the twin pin holds both RECOVERY_HINT literals to one expected string
(packages/atif-embed/tests/test_guard_twin_pin.py:76 and :79) and checks that each reaches its
raise; nothing compares the two comparison rules or the rest of the message, so the logic could
diverge while every test passes. Mitigation: treat the two functions as one. Change both in
the same commit, which is what both docstrings instruct.
harbor’s ATIF models, vendored and consumed by our ported converters
Section titled “harbor’s ATIF models, vendored and consumed by our ported converters”Producer: upstream harbor.models.trajectories (RFC 0001 data classes) and
harbor.utils.trajectory_validator at harbor 0.24.0, vendored as packages/atif-converter/src/atif_converter/domain/atif/
(UPSTREAM_VERSION in its __init__); harbor itself is a dev dependency
Consumers:
packages/atif-converter/src/atif_converter/domain/claude_code_conversion.py:981:convert_claude_code_records, our Claude Code converter, a parity port of harbor 0.24.0’s, buildingTrajectory/Step/ToolCall/Metricsfrom the public models.packages/atif-converter/src/atif_converter/domain/codex_conversion.py:924:convert_codex_records, the Codex counterpart.packages/atif-converter/src/atif_converter/infrastructure/harbor_adapter.py:validate_trajectory, the only call into the vendored validator.packages/atif-converter/tests/test_dev_only_imports_guard.py: theastguard that keepsharborandlitellmout of everysrc/tree.packages/atif-converter/tests/test_vendored_atif.py: the conformance test (the vendored files equal the installed harbor’s, and both agree on the goldens).packages/atif-converter/tests/harbor_oracle.py:118and:135: the parity ORACLE (harbor’s private converters, reached from the tests only), with frozen goldens and a live-corpus diff.
Shape:
#: Distributions that are dev-only, so no production module may import them.DEV_ONLY: frozenset[str] = frozenset({"harbor", "litellm"})Assumptions consumers make:
- The data classes are the contract, and they are versioned by ATIF. Our converters write
schema_versionexplicitly (packages/atif-converter/src/atif_converter/domain/claude_code_conversion.py:1067,packages/atif-converter/src/atif_converter/domain/codex_conversion.py:1023), so a harbor release that adds a newer default still validates what we emit; a release that changes a field fails the drift tests. - Parity is measured, not assumed.
test_harbor_oracle.pyasserts the live oracle equals the frozen goldens, andtest_parity_*asserts our output equals both. A harbor bump that changes conversion behavior surfaces as a named JSON-path diff (packages/atif-converter/tests/harbor_oracle.py:166), which is a decision to record in the fidelity policy before any re-freeze. - Side-file discovery is ours. Every
*.jsonlunder a session’s side directory is read, workflow-nested ones included, with nested path parts joined by__(packages/atif-converter/src/atif_converter/infrastructure/claude_code_converter.py:56); harbor’s own discovery cannot see those files, which is why the oracle stages them flat the same way. - harbor ships no
py.typed, so every import from it carries animport-untypedignore.
The workspace members’ ==0.3.0 sibling pins
Section titled “The workspace members’ ==0.3.0 sibling pins”Producer: packages/atif-cli/pyproject.toml:32-36 and
packages/atif-analytics/pyproject.toml:23
Consumers:
pyproject.toml:537-542: commitizen’sversion_files, which rewrites every pin on each bump.packages/atif-cli/pyproject.toml:60-65:[tool.uv.sources], the local-workspace resolution that these pins deliberately do NOT express.packages/atif-analytics/pyproject.toml:47-48: the same pairing for its single sibling dependency.pyproject.toml:494-497:[tool.commitizen] version = "0.3.0", the one version this repo publishes.pyproject.toml:16-17: the distribution is namedatif-sqlat the workspace root, while the console script’s module staysatif_cliin a member.
Shape:
dependencies = [ "atif-analytics==0.3.0", "atif-converter==0.3.0", "atif-corpus==0.3.0", "atif-duck==0.3.0", "atif-embed==0.3.0", "cyclopts>=5.1.1", "duckdb>=2.0.0,<3", "loguru>=0.7.3",]version_files = [ 'pyproject.toml:^version', 'packages/*/pyproject.toml:^version', 'packages/atif-cli/pyproject.toml:^\s*"atif-', 'packages/atif-analytics/pyproject.toml:^\s*"atif-models==',]The other five members (atif-converter, atif-corpus, atif-duck, atif-embed, atif-models) declare no sibling, so these six pins are every edge between members.
Assumptions consumers make:
- No external installer sees these pins. The member manifests are dev manifests that the
release never builds (
pyproject.toml:10-14); the one published wheel is the root’s, itsdependencieshold noatif-*entry (pyproject.toml:34-40), andpackages/atif-cli/tests/test_distribution.py:143fails if one reaches it. The comment atpackages/atif-cli/pyproject.toml:24-31reasons as if the manifest shipped: the pins keep the workspace coherent, and[tool.uv.sources]is what resolves them. - One version covers the whole repository. commitizen reads its own
versionkey rather than the PEP 621 field (pyproject.toml:494-497), andversion_filespropagates it to the published[project] versionand to every member manifest; a separate number per member for one artifact is the shape being refused. - The pin entries in
version_filesare per-file rather than globbed on purpose, because--check-consistencyrequires a hit in every matched file and five of the seven members pin no sibling (pyproject.toml:528-530). - The lockfile must land in the same commit as the versions it resolves.
pre_bump_hooksrunuv lockandgit add uv.lockafter the rewrite and before the commit (pyproject.toml:512-522), becauseuv.lockrecords every member’s version andmise run lock:checkwould otherwise fail on the release commit itself. - A breaking change moves 0.3.0 to 0.4.0, not 1.0.0, because
major_version_zero = true(pyproject.toml:503-505); reaching 1.0.0 is a decision, not a side effect of a!in a subject line.
Drift risk: a hand-edited pin, or a renamed member whose pin line stops matching its
version_files regex, goes stale silently until a release. Mitigation: --check-consistency fails
rather than skipping a file that no longer contains the current version; the guarantee is stated at
pyproject.toml:523-526.
Other contracts
Section titled “Other contracts”LossReport.to_json(): producerpackages/atif-converter/src/atif_converter/domain/fidelity.py:147, whose docstring calls the keys wire contract; consumed bypackages/atif-duck/src/atif_duck/domain/raw_readers.py:77-90asLOSS_REPORT_COLUMNSand bypackages/atif-cli/src/atif_cli/converter_adapter.py:133.record_countsandgaps_observedstayJSONbecause they are an enum-keyed dict and a sorted list of enum values.- The
meta.jsonprovenance record: written atpackages/atif-corpus/src/atif_corpus/application/materialize.py:706-731, projected atpackages/atif-duck/src/atif_duck/domain/raw_readers.py:99-121.harbor_version(the vendored models’ upstream release) andconverter_version(theatif-sqlrelease) are supplied by the CLI (packages/atif-cli/src/atif_cli/app.py:981and:986), so the corpus records which converter built it without atif-corpus importing either. - The embeddings-store row shape: the Arrow fields written at
packages/atif-embed/src/atif_embed/infrastructure/lance_store.py:182-188, of which themessage_embeddingsview exposes only some (packages/atif-duck/src/atif_duck/domain/catalog.py:281-287).text_hashandtruncatedare unreachable from SQL, so a query cannot distinguish a row embedded from its head alone from a complete one. - The Lance schema version sidecar:
SCHEMA_VERSION = 2andschema_version.jsonatpackages/atif-embed/src/atif_embed/infrastructure/lance_store.py:83-89, kept as a sidecar rather than a column because reading it must not require the table. - The user-step authorship rule table:
AUTHOR_VALUES,AUTHOR_STRIP_CHARS,AUTHOR_PREFIX_RULES, andINTERRUPT_PREFIXESatpackages/atif-duck/src/atif_duck/domain/authorship.py:37-102, which thestep_authormacro and theuser_steps,human_turns, andsession_outcomesviews compile from. atif-analytics can’t import atif-duck, sopackages/atif-analytics/src/atif_analytics/domain/authorship.py:37-102carries a twin that its pipelines and transcript renderer read. The twin is pinned as source text bypackages/atif-duck/tests/test_authorship_twin_pin.py:64, andpackages/atif-cli/tests/test_authorship_parity.py:53runs the SQL and the Python over the same steps. - The analytics parquet layout: the artifact names at
packages/atif-analytics/src/atif_analytics/domain/layout.py:25-41, of which atif-duck pins the pipeline directories as_ANALYTICS_SOURCES(packages/atif-duck/src/atif_duck/infrastructure/analytics.py:61-66).REFUSALS_DIRNAME(packages/atif-analytics/src/atif_analytics/domain/layout.py:39) has no view, as its own comment says (packages/atif-analytics/src/atif_analytics/domain/layout.py:35-38), so the refusal audit has no name inatif-sql schemaand a reader has toread_parquetits shard files directly. - The CLI exit-code taxonomy:
EXIT_CODESatpackages/atif-cli/src/atif_cli/errors.py:30-66, consumed at many sites inpackages/atif-cli/src/atif_cli/app.pyand mapped from DuckDB exceptions atpackages/atif-cli/src/atif_cli/duck_errors.py:35-64. Code 78 matters most: it means an operator must act, and unattended lanes suppress retries on it (packages/atif-cli/src/atif_cli/errors.py:49-51). PendingTextand the text stamp:packages/atif-embed/src/atif_embed/domain/text_stamp.py:51, withMAX_EMBEDDABLE_CHARS = 50_000atpackages/atif-embed/src/atif_embed/domain/text_stamp.py:35in CHARACTERS per text; the same constant governs both the row’struncatedflag and the adapter’s wire-level clip so the two can never disagree (packages/atif-embed/src/atif_embed/domain/text_stamp.py:31-34).- The
steps-view rendering semantics, mirrored without a shared symbol:packages/atif-analytics/src/atif_analytics/infrastructure/corpus_reader.py:30-44reproduces atif-duck’sstepsrules by hand (message-union flattening,extra.source_uuids[0]as the step key,agent→assistant, error recovery from the typedextra.is_erroror fromextra.tool_result_metadata.is_error), andpackages/atif-embed/src/atif_embed/infrastructure/corpus_text_rows.py:13-26reproduces some of them again. - The retryable Bedrock error codes, declared twice:
RETRY_CODESatpackages/atif-models/src/atif_models/infrastructure/bedrock_common.py:27-42, which both LLM adapters retry on throughis_retryable(:66-73), and its twin atpackages/atif-embed/src/atif_embed/infrastructure/cohere_bedrock.py:56-72. The two packages can’t import each other, so each suite pins its copy against the same spelled-out set (packages/atif-models/tests/test_openai_provider.py:342andpackages/atif-embed/tests/test_cohere_bedrock.py:442), and each copy’s comment names the other (packages/atif-embed/src/atif_embed/infrastructure/cohere_bedrock.py:56-60). - The
ATIF_SQL_settings prefix: the one env namespace across every member;packages/atif-embed/src/atif_embed/infrastructure/settings.py:64declaresoutput_dimension: int | None = None, and an unset width takes the provider’s default frompackages/atif-embed/src/atif_embed/infrastructure/settings.py:36(1024 for Cohere, 768 for Gemma). Cohere’s 1024 is the value the static catalog hardcodes asFLOAT[1024]. - The lean-import contract:
pyproject.toml:225andpyproject.toml:240-243record thatatif_cli.appmust not import duckdb, harbor, boto3, or polars at module scope, asserted in a fresh interpreter bypackages/atif-cli/tests/test_lean_import.py; the deferred-import sites exist because of it. - The other ports, satisfied the same way: atif-corpus’s
ArtifactProducer(packages/atif-corpus/src/atif_corpus/domain/ports.py:123), which atif-duck’sColumnarArtifactProducerfits (packages/atif-duck/src/atif_duck/infrastructure/columnar.py:450) and which the CLI’smaterializedoesn’t inject, andSessionSink(packages/atif-corpus/src/atif_corpus/domain/ports.py:155), which atif-duck’sDuckLakeSessionSinkfits (packages/atif-duck/src/atif_duck/infrastructure/lake.py:868) and the CLI wires in atpackages/atif-cli/src/atif_cli/app.py:1001; atif-embed’sLakeStepsPortandLakeStepSnapshot(packages/atif-embed/src/atif_embed/domain/ports.py:198and:150), which atif-cli’sDuckLakeStepsfits (packages/atif-cli/src/atif_cli/embed_lake.py:70); and atif-analytics’SessionSource(packages/atif-analytics/src/atif_analytics/domain/ports.py:40), whichTrajectoryFileSource(packages/atif-analytics/src/atif_analytics/infrastructure/corpus_reader.py:271) and atif-cli’sLakeSessionSource(packages/atif-cli/src/atif_cli/lake_sessions.py:92) fit.