loadledger 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. loadledger-0.1.0/.editorconfig +23 -0
  2. loadledger-0.1.0/.github/workflows/ci.yml +167 -0
  3. loadledger-0.1.0/.github/workflows/release.yml +57 -0
  4. loadledger-0.1.0/.gitignore +104 -0
  5. loadledger-0.1.0/.importlinter +51 -0
  6. loadledger-0.1.0/.pre-commit-config.yaml +22 -0
  7. loadledger-0.1.0/CHANGELOG.md +92 -0
  8. loadledger-0.1.0/CONTRIBUTING.md +50 -0
  9. loadledger-0.1.0/LICENSE +201 -0
  10. loadledger-0.1.0/PKG-INFO +212 -0
  11. loadledger-0.1.0/README.md +179 -0
  12. loadledger-0.1.0/SECURITY.md +41 -0
  13. loadledger-0.1.0/docs/README.md +17 -0
  14. loadledger-0.1.0/docs/mounted-table-upgrades.md +170 -0
  15. loadledger-0.1.0/docs/packages/loadledger/development-plan.md +107 -0
  16. loadledger-0.1.0/docs/packages/loadledger/spec.md +330 -0
  17. loadledger-0.1.0/docs/quickstart.md +176 -0
  18. loadledger-0.1.0/docs/quickstart.py +135 -0
  19. loadledger-0.1.0/pyproject.toml +115 -0
  20. loadledger-0.1.0/requirements/README.md +58 -0
  21. loadledger-0.1.0/requirements/ci.lock +816 -0
  22. loadledger-0.1.0/requirements/release.in +6 -0
  23. loadledger-0.1.0/requirements/release.lock +493 -0
  24. loadledger-0.1.0/src/loadledger/__about__.py +1 -0
  25. loadledger-0.1.0/src/loadledger/__init__.py +77 -0
  26. loadledger-0.1.0/src/loadledger/core.py +668 -0
  27. loadledger-0.1.0/src/loadledger/errors.py +86 -0
  28. loadledger-0.1.0/src/loadledger/memory.py +246 -0
  29. loadledger-0.1.0/src/loadledger/py.typed +0 -0
  30. loadledger-0.1.0/src/loadledger/sql.py +980 -0
  31. loadledger-0.1.0/src/loadledger/types.py +447 -0
  32. loadledger-0.1.0/tests/conftest.py +214 -0
  33. loadledger-0.1.0/tests/integration/hostapp/__init__.py +10 -0
  34. loadledger-0.1.0/tests/integration/hostapp/migrations/env.py +48 -0
  35. loadledger-0.1.0/tests/integration/hostapp/migrations/script.py.mako +28 -0
  36. loadledger-0.1.0/tests/integration/hostapp/migrations/versions/.gitkeep +0 -0
  37. loadledger-0.1.0/tests/integration/hostapp/models.py +33 -0
  38. loadledger-0.1.0/tests/integration/ledger_subprocess.py +190 -0
  39. loadledger-0.1.0/tests/integration/test_atomicity.py +180 -0
  40. loadledger-0.1.0/tests/integration/test_concurrency.py +193 -0
  41. loadledger-0.1.0/tests/integration/test_hostapp.py +293 -0
  42. loadledger-0.1.0/tests/integration/test_mounting.py +237 -0
  43. loadledger-0.1.0/tests/integration/test_quickstart.py +67 -0
  44. loadledger-0.1.0/tests/integration/test_sql_ledger.py +533 -0
  45. loadledger-0.1.0/tests/performance/test_scaling.py +71 -0
  46. loadledger-0.1.0/tests/performance/test_sql_scaling.py +164 -0
  47. loadledger-0.1.0/tests/unit/test_memory_ledger.py +391 -0
  48. loadledger-0.1.0/tests/unit/test_types.py +257 -0
  49. loadledger-0.1.0/tests/unit/test_verdicts.py +626 -0
  50. loadledger-0.1.0/tests/unit/test_windows.py +215 -0
@@ -0,0 +1,23 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+ indent_style = space
9
+ indent_size = 4
10
+
11
+ [*.py]
12
+ indent_size = 4
13
+ max_line_length = 100
14
+
15
+ [*.{toml,yml,yaml,json}]
16
+ indent_size = 2
17
+
18
+ [*.md]
19
+ trim_trailing_whitespace = false
20
+ max_line_length = off
21
+
22
+ [Makefile]
23
+ indent_style = tab
@@ -0,0 +1,167 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ format:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: actions/setup-python@v5
14
+ with: { python-version: "3.12" }
15
+ - run: pip install ruff
16
+ - run: ruff format --check .
17
+
18
+ lint:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+ - uses: actions/setup-python@v5
23
+ with: { python-version: "3.12" }
24
+ - run: pip install ruff
25
+ - run: ruff check .
26
+
27
+ types:
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@v4
31
+ - uses: actions/setup-python@v5
32
+ with: { python-version: "3.12" }
33
+ - run: pip install --require-hashes -r requirements/ci.lock
34
+ - run: pip install . --no-deps
35
+ - run: mypy src tests
36
+
37
+ boundaries:
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: actions/setup-python@v5
42
+ with: { python-version: "3.12" }
43
+ - run: pip install --require-hashes -r requirements/ci.lock
44
+ - run: pip install . --no-deps
45
+ - run: lint-imports
46
+
47
+ tests:
48
+ runs-on: ubuntu-latest
49
+ strategy:
50
+ matrix:
51
+ python-version: ["3.12", "3.13"]
52
+ steps:
53
+ - uses: actions/checkout@v4
54
+ - uses: actions/setup-python@v5
55
+ with: { python-version: "${{ matrix.python-version }}" }
56
+ - run: pip install --require-hashes -r requirements/ci.lock
57
+ - run: pip install . --no-deps
58
+ - run: pytest -m "not live and not performance" --cov --cov-report=xml
59
+
60
+ tests-314-early-warning:
61
+ runs-on: ubuntu-latest
62
+ continue-on-error: true
63
+ steps:
64
+ - uses: actions/checkout@v4
65
+ - uses: actions/setup-python@v5
66
+ with: { python-version: "3.14" }
67
+ # Deliberately unpinned: ci.lock is resolved on 3.13, and pinning a version that has no
68
+ # 3.14 wheels would defeat the purpose of an early warning.
69
+ - run: pip install -e ".[dev]"
70
+ - run: pytest -m "not live and not performance"
71
+
72
+ db-matrix:
73
+ name: tests (PostgreSQL)
74
+ runs-on: ubuntu-latest
75
+ services:
76
+ postgres:
77
+ image: postgres:16
78
+ # Credentials and database name match `DEFAULT_POSTGRES_URL` in tests/conftest.py, so the
79
+ # service this job starts is the one the integration tests look for.
80
+ env:
81
+ POSTGRES_USER: loadledger
82
+ POSTGRES_PASSWORD: loadledger
83
+ POSTGRES_DB: loadledger_test
84
+ ports: ["5432:5432"]
85
+ options: >-
86
+ --health-cmd "pg_isready -U loadledger -d loadledger_test" --health-interval 5s --health-timeout 5s --health-retries 10
87
+ steps:
88
+ - uses: actions/checkout@v4
89
+ - uses: actions/setup-python@v5
90
+ with: { python-version: "3.12" }
91
+ - run: pip install --require-hashes -r requirements/ci.lock
92
+ - run: pip install . --no-deps
93
+ - run: pytest -m "not live and not performance" tests/integration
94
+ env:
95
+ # Without this the PostgreSQL legs would *skip* here exactly as they do on a developer's
96
+ # machine, and the job would go green having tested one dialect twice. A skipped dialect
97
+ # is an untested dialect.
98
+ LOADLEDGER_REQUIRE_POSTGRES: "1"
99
+ # The tests read LOADLEDGER_POSTGRES_URL, not DATABASE_URL. Set explicitly rather than
100
+ # relying on the default, so the job states which server it is testing.
101
+ LOADLEDGER_POSTGRES_URL: postgresql+psycopg://loadledger:loadledger@localhost:5432/loadledger_test
102
+
103
+ coverage:
104
+ needs: [tests]
105
+ runs-on: ubuntu-latest
106
+ steps:
107
+ - uses: actions/checkout@v4
108
+ - uses: actions/setup-python@v5
109
+ with: { python-version: "3.12" }
110
+ - run: pip install --require-hashes -r requirements/ci.lock
111
+ - run: pip install . --no-deps
112
+ - run: pytest -m "not live and not performance" --cov --cov-report=term-missing --cov-fail-under=95
113
+
114
+ contracts:
115
+ runs-on: ubuntu-latest
116
+ steps:
117
+ - uses: actions/checkout@v4
118
+ - uses: actions/setup-python@v5
119
+ with: { python-version: "3.12" }
120
+ - run: pip install --require-hashes -r requirements/ci.lock
121
+ - run: pip install . --no-deps
122
+ - run: pytest -m contract
123
+
124
+ security:
125
+ runs-on: ubuntu-latest
126
+ steps:
127
+ - uses: actions/checkout@v4
128
+ # gitleaks scans *history*, and `actions/checkout` fetches a single commit by default.
129
+ # For a push it is handed `<first-pushed>^..<last-pushed>`, so the parent of the first
130
+ # pushed commit has to be in the object store; in a depth-1 clone it is not, and git
131
+ # answers "unknown revision", which the action reports as exit code 1. It fails the same
132
+ # way whether or not a secret exists, so a green run would not have meant anything either.
133
+ with: { fetch-depth: 0 }
134
+ - uses: actions/setup-python@v5
135
+ with: { python-version: "3.12" }
136
+ - run: pip install pip-audit
137
+ # Audit the locked sets, not the job's own environment: a bare `pip-audit` here would
138
+ # inspect an environment containing only pip-audit itself (Security Standards §11).
139
+ - run: pip-audit --require-hashes -r requirements/ci.lock
140
+ - run: pip-audit --require-hashes -r requirements/release.lock
141
+ - uses: gitleaks/gitleaks-action@v2
142
+ env:
143
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
144
+
145
+ build:
146
+ runs-on: ubuntu-latest
147
+ steps:
148
+ - uses: actions/checkout@v4
149
+ - uses: actions/setup-python@v5
150
+ with: { python-version: "3.12" }
151
+ - run: pip install --require-hashes -r requirements/release.lock
152
+ - run: python -m build --no-isolation
153
+ - run: twine check dist/*
154
+ - uses: actions/upload-artifact@v4
155
+ with: { name: dist, path: dist/ }
156
+
157
+ install-check:
158
+ needs: [build]
159
+ runs-on: ubuntu-latest
160
+ steps:
161
+ - uses: actions/checkout@v4
162
+ - uses: actions/setup-python@v5
163
+ with: { python-version: "3.12" }
164
+ - uses: actions/download-artifact@v4
165
+ with: { name: dist, path: dist/ }
166
+ - run: pip install dist/*.whl
167
+ - run: python -c "import loadledger"
@@ -0,0 +1,57 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*.*.*"]
6
+ workflow_dispatch: # manual TestPyPI dry run; see publish-testpypi below
7
+
8
+ permissions:
9
+ id-token: write # required for PyPI Trusted Publishing
10
+ contents: write # required to create the GitHub release
11
+
12
+ jobs:
13
+ release:
14
+ # Tag pushes only. Without this, clicking "Run workflow" for the TestPyPI dry run below would
15
+ # also fire this job and publish to real PyPI — the exact opposite of a dry run.
16
+ if: github.event_name == 'push'
17
+ runs-on: ubuntu-latest
18
+ environment: pypi # must match the Environment name set on the PyPI trusted publisher
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-python@v5
22
+ with: { python-version: "3.12" }
23
+ # Byte-for-byte the dry run's build chain below. A real release that resolved `build` and
24
+ # `hatchling` fresh from PyPI would not be the artifact the dry run proved.
25
+ - run: pip install --require-hashes -r requirements/release.lock
26
+ - run: python -m build --no-isolation
27
+ - run: twine check dist/*
28
+ - run: pip install "dist/$(ls dist | grep .whl)[dev]"
29
+ - run: pytest -m "not live and not performance"
30
+ - name: Publish to PyPI
31
+ uses: pypa/gh-action-pypi-publish@release/v1
32
+ - name: Create GitHub release
33
+ uses: softprops/action-gh-release@v2
34
+ with:
35
+ generate_release_notes: true
36
+ files: dist/*
37
+
38
+ publish-testpypi:
39
+ # Manual only, via Actions -> Release -> Run workflow. Packaging and Release Standards §6
40
+ # requires a successful TestPyPI publish ahead of a package's first real release; 0.1.0 is
41
+ # this package's first published version, so run this once before tagging v0.1.0. Later
42
+ # releases may skip it, or use it again as a dry run.
43
+ if: github.event_name == 'workflow_dispatch'
44
+ runs-on: ubuntu-latest
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+ - uses: actions/setup-python@v5
48
+ with: { python-version: "3.12" }
49
+ - run: pip install --require-hashes -r requirements/release.lock
50
+ - run: python -m build --no-isolation
51
+ - run: twine check dist/*
52
+ - run: pip install "dist/$(ls dist | grep .whl)[dev]"
53
+ - run: pytest -m "not live and not performance"
54
+ - name: Publish to TestPyPI
55
+ uses: pypa/gh-action-pypi-publish@release/v1
56
+ with:
57
+ repository-url: https://test.pypi.org/legacy/
@@ -0,0 +1,104 @@
1
+ # ---- Python ----
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+ MANIFEST
23
+
24
+ # ---- Packaging / build backends ----
25
+ pip-wheel-metadata/
26
+ share/python-wheels/
27
+
28
+ # ---- Testing / coverage ----
29
+ .pytest_cache/
30
+ .cache
31
+ .coverage
32
+ .coverage.*
33
+ coverage.xml
34
+ *.cover
35
+ *.py,cover
36
+ htmlcov/
37
+ nosetests.xml
38
+ .hypothesis/
39
+
40
+ # ---- Type checking / linting ----
41
+ .mypy_cache/
42
+ .dmypy.json
43
+ dmypy.json
44
+ .ruff_cache/
45
+ .pytype/
46
+
47
+ # ---- Virtual environments ----
48
+ .venv/
49
+ venv/
50
+ ENV/
51
+ env/
52
+ env.bak/
53
+ venv.bak/
54
+ .python-version
55
+
56
+ # ---- Distribution / dependency locking ----
57
+ # requirements/*.lock is deliberately NOT ignored. The lock files are committed
58
+ # inputs: CI and release jobs install from them with --require-hashes (Packaging
59
+ # and Release Standards §4, Security Standards §11), so an ignored lock file
60
+ # means a checkout that cannot build.
61
+
62
+ # ---- Editors / OS ----
63
+ .vscode/
64
+ .idea/
65
+ *.swp
66
+ *.swo
67
+ .DS_Store
68
+ Thumbs.db
69
+
70
+ # ---- Suite runtime state (this component's local data) ----
71
+ # The application writes to XDG paths at runtime (~/.config, ~/.local/share,
72
+ # ~/.local/state per Master Architecture §1.2), never inside the repository.
73
+ # These entries only guard against a developer pointing XDG_* at the repo
74
+ # during local testing.
75
+ .local/
76
+ .config/
77
+ *.sqlite3
78
+ *.sqlite3-journal
79
+ *.sqlite3-wal
80
+ *.sqlite3-shm
81
+ /data/
82
+ /logs/
83
+ /backups/
84
+ /artifacts/
85
+ /exports/
86
+
87
+ # ---- Secrets ----
88
+ # Per Security Standards §8: secrets are never committed. Config files may
89
+ # only name where a secret comes from (*_env / *_file), never the value.
90
+ .env
91
+ .env.*
92
+ *.key
93
+ *.pem
94
+ secrets.toml
95
+
96
+ # ---- Node-free JS assets (MirrorWall consumers may still use a local tool) ----
97
+ node_modules/
98
+
99
+ # ---- Build artifacts from docs generation ----
100
+ docs/api/openapi-v1.json.tmp
101
+
102
+ # ---- import-linter cache ----
103
+ # `lint-imports` writes this on every gate run; it is a cache, never an input.
104
+ .import_linter_cache/
@@ -0,0 +1,51 @@
1
+ [importlinter]
2
+ root_package = loadledger
3
+ include_external_packages = True
4
+
5
+ [importlinter:contract:no-application-imports]
6
+ name = LoadLedger must not import applications
7
+ type = forbidden
8
+ source_modules = loadledger
9
+ forbidden_modules =
10
+ freeweight
11
+ loadcoach
12
+ ideapress
13
+ promptcadence
14
+
15
+ # `commissioner` was called `spotcheck` until py/Commissioner's rename (7077cc4). Both spellings
16
+ # are listed: a forbidden module that no longer exists forbids nothing, silently, so dropping the
17
+ # old name would quietly stop guarding against an import written before the rename.
18
+ [importlinter:contract:no-sibling-packages]
19
+ name = LoadLedger must not import sibling capability packages
20
+ type = forbidden
21
+ source_modules = loadledger
22
+ forbidden_modules =
23
+ setspec
24
+ modelrack
25
+ sweatmeter
26
+ weightsdb
27
+ mirrorwall
28
+ cutctx
29
+ toolyard
30
+ commissioner
31
+ spotcheck
32
+
33
+ # Replaces `no-sql-in-phase-1`, which forbade `sqlalchemy` outright while `loadledger.sql` did not
34
+ # exist. Same rule, with the one exemption Phase 2 earned, and it is the mechanical statement of
35
+ # ADR-0050 decision 4: `sqlalchemy` is an optional extra (`loadledger[sql]`) so the pure-value core
36
+ # stays installable with nothing but baseaicore. Weakening this contract — or deleting it, which
37
+ # looks identical in a diff — makes `pip install loadledger` drag an ORM into every consumer that
38
+ # only wanted to add up tokens.
39
+ #
40
+ # `alembic` has no exemption at all: the host owns every migration (ADR-0050 decision 5), so no
41
+ # module in this package imports it, `loadledger.sql` included. It is a test dependency for the
42
+ # miniature host and never a runtime one.
43
+ [importlinter:contract:only-the-sql-module-imports-sqlalchemy]
44
+ name = Only loadledger.sql may import SQLAlchemy, and nothing may import Alembic
45
+ type = forbidden
46
+ source_modules = loadledger
47
+ forbidden_modules =
48
+ sqlalchemy
49
+ alembic
50
+ ignore_imports =
51
+ loadledger.sql -> sqlalchemy
@@ -0,0 +1,22 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.6.9
4
+ hooks:
5
+ - id: ruff
6
+ args: [--fix]
7
+ - id: ruff-format
8
+ - repo: https://github.com/pre-commit/pre-commit-hooks
9
+ rev: v4.6.0
10
+ hooks:
11
+ - id: trailing-whitespace
12
+ - id: end-of-file-fixer
13
+ - id: check-toml
14
+ - id: check-json
15
+ - id: check-added-large-files
16
+ - id: check-merge-conflict
17
+ - id: mixed-line-ending
18
+ args: [--fix=lf]
19
+ - repo: https://github.com/gitleaks/gitleaks
20
+ rev: v8.18.4
21
+ hooks:
22
+ - id: gitleaks
@@ -0,0 +1,92 @@
1
+ # Changelog
2
+
3
+ All notable changes to `loadledger` are documented here.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
5
+ [Semantic Versioning](https://semver.org/), pre-1.0 per
6
+ packaging and release standards §3.
7
+
8
+ ## [0.1.0] — 2026-09-02
9
+
10
+ ### Added
11
+ - Repository scaffold: toolchain copied from `py/WeightsDB` (hatchling, ruff, mypy strict,
12
+ import-linter, pytest with `pytest-randomly`, hash-pinned `requirements/` locks, CI and release
13
+ workflows), with the PostgreSQL matrix job dropped — this package touches no database.
14
+ - Phase 1, the pure core: `CeilingScope`, `BudgetCeiling`, `Debit`, `CeilingVerdict`,
15
+ `LedgerEntry`, the `Ledger` protocol, `BalanceBook`, `InMemoryLedger`, `utc_day_start`,
16
+ `utc_day_key`, and the `LedgerError` / `CurrencyMismatch` / `InvalidCeiling` / `UnknownRun`
17
+ hierarchy. No I/O, no SQL, no logging, no environment reads.
18
+ - `CeilingVerdict.unpriced_debit_count` and `CeilingVerdict.unmetered_debit_count`, beyond the
19
+ shape in spec §7: contract 2 requires the unpriced count to ride on the money verdict, and the
20
+ same honesty is owed to the token balance when a provider leaves a token class unreported.
21
+ - `Ledger.declare_run`, beyond the protocol in spec §7: spec §13 defines a run as existing "once
22
+ debited **or declared**", and §7 gave no way to declare one.
23
+ - `as_canonical()` on `BudgetCeiling`, `Debit`, `CeilingVerdict` and `LedgerEntry`, following
24
+ BaseAiCore's own pattern — spec contract 4 requires byte-identical verdict serializations, and
25
+ `baseaicore.canonical_json` needs a mapping form to produce them. `Debit.as_canonical` omits
26
+ the cost deliberately: usage and `pricing_hash` are the stored facts (ADR-0030 rule 1).
27
+ - `PartialPricing` and the keyword-only `BudgetCeiling.partial_pricing` (default `FLOOR`;
28
+ `STRICT` requires a money bound, else `InvalidCeiling`), and `CeilingVerdict.untotalled_debit_count`
29
+ — the subset of the unpriced count that carried an estimate which did not total, which is what
30
+ a strict ceiling fires on. Both appear in the canonical forms, so the goldens changed (ADR-0069).
31
+
32
+ ### Changed
33
+ - A debit whose estimate did not total now accumulates the components that *were* priced into the
34
+ money balance as a floor, instead of adding nothing (ADR-0069, reversing spec contract 2 as
35
+ first written). On a floor, `exceeded` is certain when `True` and not when `False`; a `STRICT`
36
+ ceiling treats an untotalled estimate in its window as exceeding, at pre-flight too. A debit
37
+ with no estimate still touches no money balance, and never trips a strict ceiling.
38
+
39
+ - Phase 2, the durable half: `loadledger.sql` under the new `loadledger[sql]` extra (ADR-0050).
40
+ - `mount_ledger_tables(metadata, *, prefix="ledger_") -> LedgerTables` adds four tables —
41
+ `ledger_entries`, `ledger_balances`, `ledger_balance_money`, `ledger_runs` — to an
42
+ application's own `MetaData`, so they appear in the application's own Alembic autogenerate and
43
+ the application owns the rows, the backups and the retention. The package owns no engine, no
44
+ session, no URL, no environment variable, no file and no migration history; `create_all`
45
+ appears nowhere in `src/`, and nothing is created on import.
46
+ - `SqlLedger(session_factory, ceilings, *, clock, table_prefix="ledger_")` implements the whole
47
+ `Ledger` protocol, `declare_run` included, over one injected session factory. It evaluates
48
+ through the same `BalanceBook` as `InMemoryLedger`, so the arithmetic and the honesty rules
49
+ have one implementation and not one per backend.
50
+ - `LedgerTables`, a frozen handle with `prefix`, `entries`, `balances`, `balance_money`, `runs`,
51
+ `metadata` and `all_tables`.
52
+ - `UnsupportedDialect` (`LEDGER_UNSUPPORTED_DIALECT`): ADR-0006 admits SQLite and PostgreSQL, and
53
+ a third dialect is refused at the first statement rather than found as a syntax error inside a
54
+ money transaction. Now in spec §7's error list and §13's table.
55
+ - `loadledger.core` gains the seams a durable ledger needs, all documented: `DebitContribution`,
56
+ `contribution_of`, `BalanceBook.windows_touched` / `window_for` / `seed`, `resolved_debit` and
57
+ `is_unpriced`. The package's top-level `__all__` is unchanged.
58
+ - `docs/quickstart.md` and the standalone `docs/quickstart.py` it publishes the output of (spec §20
59
+ acceptance criterion 2), with a test that runs the script so it cannot rot.
60
+ - `docs/mounted-table-upgrades.md`: the upgrade-note template and migration recipe LoadLedger ships
61
+ when a mounted table changes shape, since the host owns every migration (spec §19, ADR-0050
62
+ decision 5), with one worked example.
63
+ - CI gains a `db-matrix` job running the integration tests against PostgreSQL 16 with
64
+ `LOADLEDGER_REQUIRE_POSTGRES=1`, so a dialect cannot be skipped into a green run.
65
+
66
+ ### Changed
67
+ - `.importlinter`'s `no-sql-in-phase-1` is **replaced** by
68
+ `only-the-sql-module-imports-sqlalchemy`: same forbidden modules, one ignored import for
69
+ `loadledger.sql -> sqlalchemy`, and no exemption at all for `alembic`. The `no-sibling-packages`
70
+ contract's `spotcheck` entry gains `commissioner`, the package's name since `7077cc4`; the old
71
+ spelling is kept, because a forbidden module that no longer exists forbids nothing.
72
+ - `pytest` `addopts` gains `-ra`, so a skipped PostgreSQL leg always names itself in the summary.
73
+
74
+ ### Specification
75
+ - Spec §7, §10, §11, §13 and §15 were amended to describe what Phase 2 built, and the amendments
76
+ were accepted before release:
77
+ - §7 gains `LedgerTables`'s field list and `UnsupportedDialect`, and states what a durable
78
+ ledger's `entries()` returns.
79
+ - §10 names the four mounted tables and their keys, says why money is a table rather than a
80
+ column, and makes the `BigInteger` width part of the mounted contract.
81
+ - §11 contract 1 states that a durable ledger does not persist the `CostEstimate` — the one
82
+ place a consumer swapping `InMemoryLedger` for `SqlLedger` sees a difference.
83
+ - §13 gains rows for the prefix `ValueError` and for `UnsupportedDialect`.
84
+ - §15's single `entries` budget is split: for `SqlLedger` on SQLite, the query is ≤ 100 ms and
85
+ full materialization ≤ 250 ms. `InMemoryLedger` keeps ≤ 100 ms. The old single figure was set
86
+ before `SqlLedger` existed and was never about constructing ten thousand value objects.
87
+
88
+ ### Performance, as measured
89
+ - `debit` with three ceilings ~1.5 ms (budget 5 ms) and flat as history grows — balances are
90
+ maintained, not recomputed. `would_exceed` ~0.4 ms (budget 2 ms). `entries` over a 10 000-entry
91
+ run: ~17 ms for the query (budget 100 ms), ~155 ms fully materialized (budget 250 ms). All
92
+ inside the amended §15.
@@ -0,0 +1,50 @@
1
+ # Contributing to LoadLedger
2
+
3
+ This repository is one component of the Local AI Suite. Before changing anything, read
4
+ `docs/packages/loadledger/spec.md` and the current
5
+ phase in `development-plan.md` — both are in this repository's `docs/` folder, copied from the suite's
6
+ central documentation set so this repository can be worked on independently.
7
+
8
+ ## Development setup
9
+
10
+ ```bash
11
+ python -m venv .venv
12
+ source .venv/bin/activate
13
+ pip install -e ".[dev]"
14
+ pre-commit install
15
+ ```
16
+
17
+ ## Required reading, in order
18
+
19
+ 1. This component's spec — purpose, scope, non-goals, contracts.
20
+ 2. `development-plan.md` in the same folder — the phase you are implementing, its acceptance criteria and its tests.
21
+
22
+ ## Rules that apply to every change here
23
+
24
+ * Follow the architecture's dependency direction.
25
+ This repository's `.importlinter` enforces it in CI; do not weaken that file to make an import work.
26
+ * No business logic in a route handler or CLI command body — both call one service method and render
27
+ .
28
+ * An unavailable measurement is `Unsupported`, never zero, never `None` used as a substitute
29
+ .
30
+ * Prompts are versioned JSON records, not Python string literals.
31
+ * Every phase's acceptance criteria in `development-plan.md` must be demonstrable, not merely
32
+ test-covered — the plan states what to run and what a person should see.
33
+
34
+ ## Before opening a pull request
35
+
36
+ ```bash
37
+ ruff format --check .
38
+ ruff check .
39
+ mypy src tests
40
+ lint-imports
41
+ pytest -m "not live and not performance"
42
+ ```
43
+
44
+ All of the above run in CI (`.github/workflows/ci.yml`); a red CI run blocks merge.
45
+
46
+ ## Commit style
47
+
48
+ Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, `perf:`, `build:`,
49
+ `ci:`), with `!` or a `BREAKING CHANGE:` footer for breaking changes. Update `CHANGELOG.md` under
50
+ `## [Unreleased]` for any user-visible change.