sumac-home 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.
- sumac_home-0.1.0/.claude/settings.json +15 -0
- sumac_home-0.1.0/.gitattributes +1 -0
- sumac_home-0.1.0/.github/workflows/ci.yml +20 -0
- sumac_home-0.1.0/.gitignore +5 -0
- sumac_home-0.1.0/.python-version +1 -0
- sumac_home-0.1.0/PKG-INFO +90 -0
- sumac_home-0.1.0/README.md +79 -0
- sumac_home-0.1.0/docs/FORMAT.md +61 -0
- sumac_home-0.1.0/docs/LAYOUT.md +21 -0
- sumac_home-0.1.0/docs/PLAN.md +136 -0
- sumac_home-0.1.0/prompt.md +48 -0
- sumac_home-0.1.0/pyproject.toml +40 -0
- sumac_home-0.1.0/src/sumac/__init__.py +5 -0
- sumac_home-0.1.0/src/sumac/cli.py +294 -0
- sumac_home-0.1.0/src/sumac/config.py +71 -0
- sumac_home-0.1.0/src/sumac/errors.py +21 -0
- sumac_home-0.1.0/src/sumac/ledger.py +114 -0
- sumac_home-0.1.0/src/sumac/models.py +110 -0
- sumac_home-0.1.0/src/sumac/passphrase.py +34 -0
- sumac_home-0.1.0/src/sumac/paths.py +51 -0
- sumac_home-0.1.0/src/sumac/py.typed +0 -0
- sumac_home-0.1.0/src/sumac/render.py +120 -0
- sumac_home-0.1.0/src/sumac/schemas.py +157 -0
- sumac_home-0.1.0/src/sumac/store.py +94 -0
- sumac_home-0.1.0/src/sumac/vault.py +21 -0
- sumac_home-0.1.0/tests/__init__.py +0 -0
- sumac_home-0.1.0/tests/conftest.py +51 -0
- sumac_home-0.1.0/tests/test_cli.py +164 -0
- sumac_home-0.1.0/tests/test_config.py +79 -0
- sumac_home-0.1.0/tests/test_leak.py +83 -0
- sumac_home-0.1.0/tests/test_ledger.py +232 -0
- sumac_home-0.1.0/tests/test_models.py +56 -0
- sumac_home-0.1.0/tests/test_schemas.py +79 -0
- sumac_home-0.1.0/tests/test_store.py +69 -0
- sumac_home-0.1.0/uv.lock +477 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PostToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Edit|Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "f=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get(\"tool_input\",{}).get(\"file_path\",\"\"))'); case \"$f\" in *.py) uv run ruff format \"$f\" ;; esac"
|
|
10
|
+
}
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
data/**/*.jsonl merge=union
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [master]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v3
|
|
14
|
+
with:
|
|
15
|
+
enable-cache: true
|
|
16
|
+
- run: uv sync --all-groups
|
|
17
|
+
- run: uv run ruff format --check .
|
|
18
|
+
- run: uv run ruff check .
|
|
19
|
+
- run: uv run ty check
|
|
20
|
+
- run: uv run pytest
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sumac-home
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Home grocery inventory app
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: pydantic>=2
|
|
7
|
+
Requires-Dist: rich>=13
|
|
8
|
+
Requires-Dist: sealedlog>=0.1.0
|
|
9
|
+
Requires-Dist: typer>=0.12
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# sumac
|
|
13
|
+
|
|
14
|
+
**🍋 sumac: home grocery inventory app**
|
|
15
|
+
|
|
16
|
+
Encrypted-at-rest grocery inventory for a household sharing one git repo and one passphrase.
|
|
17
|
+
Locations, products, and quantities are never visible to someone holding the repo without the
|
|
18
|
+
passphrase — not even in file or directory names. See `docs/FORMAT.md` for the on-disk format
|
|
19
|
+
and threat model, and `docs/LAYOUT.md` for what's read-only vs mutable.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
Published on PyPI as `sumac-home` (`sumac` was taken); the command is still `sumac`.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
uv tool install sumac-home # or: pip install sumac-home
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For development (this checkout):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
uv sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Depends on [`sealedlog`](https://pypi.org/project/sealedlog/) (the encrypted append-only log
|
|
36
|
+
primitive) from PyPI.
|
|
37
|
+
|
|
38
|
+
## Passphrase
|
|
39
|
+
|
|
40
|
+
Set `SUMAC_PASSPHRASE`, or sumac will prompt interactively. The passphrase is shared by every
|
|
41
|
+
user of the household's vault.
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
sumac init # once, creates data/
|
|
47
|
+
sumac config add-location "Fridge" --id fridge
|
|
48
|
+
sumac config add-location "Pantry" --id pantry
|
|
49
|
+
sumac config show
|
|
50
|
+
|
|
51
|
+
sumac add purchase milk 2 l --to fridge
|
|
52
|
+
sumac add consumption milk 1 l --from fridge
|
|
53
|
+
sumac add movement rice 1 kg --from pantry --to fridge
|
|
54
|
+
sumac snapshot fridge "milk=1/l" "eggs=6/ct" # reconciliation: resets fridge's products
|
|
55
|
+
|
|
56
|
+
sumac status # current inventory, all locations
|
|
57
|
+
sumac status fridge # current inventory, one location
|
|
58
|
+
sumac find milk # where is milk right now?
|
|
59
|
+
sumac log # full ordered event log
|
|
60
|
+
sumac verify # re-authenticate every line; check actors
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
All commands take `--data-dir` (default `data`, or `$SUMAC_DATA_DIR`).
|
|
64
|
+
|
|
65
|
+
### Locations nest
|
|
66
|
+
|
|
67
|
+
A location can have a parent, so shelves, doors, bins, drawers — anything — nest under a
|
|
68
|
+
container to arbitrary depth. There's no separate "shelf" or "grid" type; a sub-location is just
|
|
69
|
+
another location with `--parent` set.
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
sumac config add-location "Door" --id fridge-door --parent fridge
|
|
73
|
+
sumac config add-array "Shelf" --parent fridge --count 4 # Shelf 1..4 under fridge
|
|
74
|
+
sumac config add-grid "Bin" --parent pantry --rows 3 --cols 4 # Bin R1C1..R3C4 under pantry
|
|
75
|
+
sumac config show # renders the tree
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`sumac status <location>` and `sumac find` both include everything nested under a location, not
|
|
79
|
+
just that exact node — `sumac status fridge` sums the fridge itself, its door, and its shelves in
|
|
80
|
+
one pass. Query a sub-location directly (e.g. `sumac status fridge-door`) to scope to just that
|
|
81
|
+
node and its own descendants.
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
uv run ruff format .
|
|
87
|
+
uv run ruff check .
|
|
88
|
+
uv run ty check
|
|
89
|
+
uv run pytest
|
|
90
|
+
```
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# sumac
|
|
2
|
+
|
|
3
|
+
**🍋 sumac: home grocery inventory app**
|
|
4
|
+
|
|
5
|
+
Encrypted-at-rest grocery inventory for a household sharing one git repo and one passphrase.
|
|
6
|
+
Locations, products, and quantities are never visible to someone holding the repo without the
|
|
7
|
+
passphrase — not even in file or directory names. See `docs/FORMAT.md` for the on-disk format
|
|
8
|
+
and threat model, and `docs/LAYOUT.md` for what's read-only vs mutable.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
Published on PyPI as `sumac-home` (`sumac` was taken); the command is still `sumac`.
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
uv tool install sumac-home # or: pip install sumac-home
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
For development (this checkout):
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
uv sync
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Depends on [`sealedlog`](https://pypi.org/project/sealedlog/) (the encrypted append-only log
|
|
25
|
+
primitive) from PyPI.
|
|
26
|
+
|
|
27
|
+
## Passphrase
|
|
28
|
+
|
|
29
|
+
Set `SUMAC_PASSPHRASE`, or sumac will prompt interactively. The passphrase is shared by every
|
|
30
|
+
user of the household's vault.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
sumac init # once, creates data/
|
|
36
|
+
sumac config add-location "Fridge" --id fridge
|
|
37
|
+
sumac config add-location "Pantry" --id pantry
|
|
38
|
+
sumac config show
|
|
39
|
+
|
|
40
|
+
sumac add purchase milk 2 l --to fridge
|
|
41
|
+
sumac add consumption milk 1 l --from fridge
|
|
42
|
+
sumac add movement rice 1 kg --from pantry --to fridge
|
|
43
|
+
sumac snapshot fridge "milk=1/l" "eggs=6/ct" # reconciliation: resets fridge's products
|
|
44
|
+
|
|
45
|
+
sumac status # current inventory, all locations
|
|
46
|
+
sumac status fridge # current inventory, one location
|
|
47
|
+
sumac find milk # where is milk right now?
|
|
48
|
+
sumac log # full ordered event log
|
|
49
|
+
sumac verify # re-authenticate every line; check actors
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
All commands take `--data-dir` (default `data`, or `$SUMAC_DATA_DIR`).
|
|
53
|
+
|
|
54
|
+
### Locations nest
|
|
55
|
+
|
|
56
|
+
A location can have a parent, so shelves, doors, bins, drawers — anything — nest under a
|
|
57
|
+
container to arbitrary depth. There's no separate "shelf" or "grid" type; a sub-location is just
|
|
58
|
+
another location with `--parent` set.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
sumac config add-location "Door" --id fridge-door --parent fridge
|
|
62
|
+
sumac config add-array "Shelf" --parent fridge --count 4 # Shelf 1..4 under fridge
|
|
63
|
+
sumac config add-grid "Bin" --parent pantry --rows 3 --cols 4 # Bin R1C1..R3C4 under pantry
|
|
64
|
+
sumac config show # renders the tree
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`sumac status <location>` and `sumac find` both include everything nested under a location, not
|
|
68
|
+
just that exact node — `sumac status fridge` sums the fridge itself, its door, and its shelves in
|
|
69
|
+
one pass. Query a sub-location directly (e.g. `sumac status fridge-door`) to scope to just that
|
|
70
|
+
node and its own descendants.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
uv run ruff format .
|
|
76
|
+
uv run ruff check .
|
|
77
|
+
uv run ty check
|
|
78
|
+
uv run pytest
|
|
79
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# On-disk format and threat model
|
|
2
|
+
|
|
3
|
+
## Threat model
|
|
4
|
+
|
|
5
|
+
Anyone holding the repo but not the passphrase must learn nothing about the home's layout or
|
|
6
|
+
its contents: no location names, no product names, no quantities. Accepted leakage: record
|
|
7
|
+
count, approximate record size, commit timestamps, and OS usernames.
|
|
8
|
+
|
|
9
|
+
Households share one passphrase. Ownership of a user's log is a convention
|
|
10
|
+
(`getpass.getuser()`), not enforced by file permissions — the design makes violations
|
|
11
|
+
*detectable* (`sumac verify`), not impossible.
|
|
12
|
+
|
|
13
|
+
## Layout
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
data/
|
|
17
|
+
vault.json # plaintext: format version, Argon2id params, salt, verifier
|
|
18
|
+
config.jsonl.enc # encrypted JSONL, append-only: location definitions
|
|
19
|
+
log/<osuser>.jsonl # encrypted JSONL, append-only, one per user: changes and snapshots
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Every path component is a fixed literal or an OS username — never derived from a location or
|
|
23
|
+
product name.
|
|
24
|
+
|
|
25
|
+
## Sealed line
|
|
26
|
+
|
|
27
|
+
The line-sealing primitive — AEAD, nonces, base64 framing, AAD, Argon2id key derivation, and
|
|
28
|
+
the wrong-passphrase verifier — is not sumac's own code. It's provided by
|
|
29
|
+
[`sealedlog`](https://pypi.org/project/sealedlog/), a standalone library extracted from an
|
|
30
|
+
earlier version of this app; see its `docs/FORMAT.md` for the full spec. Summary of what that
|
|
31
|
+
buys sumac:
|
|
32
|
+
|
|
33
|
+
Each line is `base64(nonce‖ciphertext‖tag)`, sealed with XChaCha20-Poly1305 and a fresh random
|
|
34
|
+
24-byte nonce. Appending is a byte-append, so git packs the history well. This costs per-line
|
|
35
|
+
ciphertext overhead and leaks record count and approximate size — accepted per the threat model.
|
|
36
|
+
|
|
37
|
+
## AAD binding
|
|
38
|
+
|
|
39
|
+
Every sealed line is bound to the stream it belongs to via associated data built from sumac's
|
|
40
|
+
namespace (`sumac.NAMESPACE`, `"sumac"`) and the `stream_id` (`"config"` or `"log:<osuser>"`) —
|
|
41
|
+
see `sealedlog`'s AAD scheme for the exact byte layout. A line copied out of one stream into
|
|
42
|
+
another fails to authenticate. This is what makes the ownership convention auditable: it can't
|
|
43
|
+
stop a user from truncating their own file, but it prevents laundering a record into someone
|
|
44
|
+
else's history.
|
|
45
|
+
|
|
46
|
+
## Key derivation
|
|
47
|
+
|
|
48
|
+
The key is derived from the shared passphrase via Argon2id (`sealedlog.Vault`), with a random
|
|
49
|
+
salt and the KDF params stored in `vault.json` alongside sumac's own `format_version`. A
|
|
50
|
+
`verifier` — a known plaintext sealed at vault-creation time — lets `sealedlog.Vault.unlock`
|
|
51
|
+
reject a wrong passphrase immediately with `WrongPassphraseError`, instead of producing garbage
|
|
52
|
+
downstream. `sumac.vault` wraps `Vault.create`/`Vault.unlock` with sumac's namespace baked in so
|
|
53
|
+
call sites can't typo it.
|
|
54
|
+
|
|
55
|
+
## Versioning
|
|
56
|
+
|
|
57
|
+
Every record carries `schema_version`. A reader that encounters a record from a newer schema
|
|
58
|
+
than it understands raises an "upgrade sumac" error rather than guessing.
|
|
59
|
+
|
|
60
|
+
`data/**/*.jsonl` is declared `merge=union` in `.gitattributes`: correct for append-only
|
|
61
|
+
streams, and it keeps concurrent pushes from conflicting.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Read-only vs mutable
|
|
2
|
+
|
|
3
|
+
Read-only-ness is a convention, not enforced by file permissions. `sumac verify` detects
|
|
4
|
+
violations after the fact; nothing prevents them at write time except `store.append`'s check
|
|
5
|
+
that a `log:<osuser>` stream matches the current OS user.
|
|
6
|
+
|
|
7
|
+
| Path | Mutability | Notes |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| `src/sumac/` | Read-only for users | Pulled with the app; users don't edit it. |
|
|
10
|
+
| `pyproject.toml`, `uv.lock`, CI, hooks | Read-only for users | App-level tooling. |
|
|
11
|
+
| `data/vault.json` | Written once, by `sumac init` | KDF params and verifier; not append-only. |
|
|
12
|
+
| `data/config.jsonl.enc` | Mutable by any user | Shared location registry; append-only. |
|
|
13
|
+
| `data/log/<osuser>.jsonl` | Mutable only by `<osuser>` | Everyone else: read-only. |
|
|
14
|
+
|
|
15
|
+
A user appends their own changes and snapshots to `data/log/<their-username>.jsonl` and never
|
|
16
|
+
writes into another user's log. Corrections are new records carrying `supersedes: <record-id>`;
|
|
17
|
+
no one ever rewrites or deletes a line in a log that isn't theirs — or, for that matter, in
|
|
18
|
+
their own, since the format is append-only end to end.
|
|
19
|
+
|
|
20
|
+
Run `sumac verify` after pulling to confirm every line in every log still authenticates under
|
|
21
|
+
its own stream, and that no record's `actor` field disagrees with the file it lives in.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# sumac — encrypted home grocery inventory
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
`/workspace` is a greenfield repo (`README.md`, `prompt.md`, one commit). `prompt.md` specifies a Python
|
|
6
|
+
app that logs a household's grocery inventory across locations, with everything sensitive encrypted at
|
|
7
|
+
rest so the repo can be pushed to a normal git remote.
|
|
8
|
+
|
|
9
|
+
The threat model is narrow and worth stating, because it drives the whole design: **anyone holding the
|
|
10
|
+
repo but not the passphrase must learn nothing about the home's layout or its contents.** That means the
|
|
11
|
+
location config is encrypted too, and no filename, directory name, or git metadata may be derived from a
|
|
12
|
+
location or product name. What we knowingly accept as leakage: record count, approximate record size,
|
|
13
|
+
timestamps of commits, and OS usernames.
|
|
14
|
+
|
|
15
|
+
Households share one passphrase. Users `git pull` the app, append their own records, and push. Ownership
|
|
16
|
+
is a convention (`getpass.getuser()`), not file permissions — so the design makes violations *detectable*
|
|
17
|
+
rather than impossible.
|
|
18
|
+
|
|
19
|
+
Decisions taken with the user: passphrase from `SUMAC_PASSPHRASE` else interactive prompt; Typer + rich
|
|
20
|
+
for the CLI; one JSONL log per user.
|
|
21
|
+
|
|
22
|
+
## On-disk layout
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
data/ # mutable — users append here
|
|
26
|
+
vault.json # plaintext header: format version, KDF params, salt, verifier
|
|
27
|
+
config.jsonl.enc # encrypted JSONL, append-only; latest revision wins
|
|
28
|
+
log/<osuser>.jsonl # encrypted JSONL, one per user, append-only
|
|
29
|
+
src/sumac/ # read-only for users
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Every path component is a fixed literal or an OS username. Nothing is derived from user data.
|
|
33
|
+
|
|
34
|
+
**Sealed line** — `base64(nonce‖ciphertext‖tag)` + `\n`, XChaCha20-Poly1305, fresh 24-byte nonce per line.
|
|
35
|
+
Appending is a byte-append; git packs it well.
|
|
36
|
+
|
|
37
|
+
**AAD binds each line to its stream**: `b"sumac/v1|" + stream_id`, where `stream_id` is `"config"` or
|
|
38
|
+
`"log:<osuser>"`. A ciphertext line copied out of alice's log into bob's fails to open. This is what makes
|
|
39
|
+
the ownership convention auditable — it can't stop alice from truncating her own file, but no one can
|
|
40
|
+
launder a record into someone else's history.
|
|
41
|
+
|
|
42
|
+
**`vault.json`** holds `format_version`, Argon2id params (`salt`, `opslimit`, `memlimit`), and a `verifier`
|
|
43
|
+
line sealed under AAD `sumac/v1|verifier`. A wrong passphrase fails there with a clear message instead of
|
|
44
|
+
producing garbage downstream.
|
|
45
|
+
|
|
46
|
+
## Modules
|
|
47
|
+
|
|
48
|
+
One scope each; `models.py` is standalone so the data model can be edited without touching anything else.
|
|
49
|
+
|
|
50
|
+
| Module | Responsibility |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `sumac/__init__.py` | `__version__`, `SCHEMA_VERSION`, `FORMAT_VERSION` |
|
|
53
|
+
| `sumac/models.py` | Frozen dataclasses. No I/O, no crypto, no pydantic imports. |
|
|
54
|
+
| `sumac/schemas.py` | Pydantic v2 models at ingest boundaries + `to_domain()` converters |
|
|
55
|
+
| `sumac/crypto.py` | `derive_key`, `seal_line`, `open_line`, `new_header`, `check_passphrase` |
|
|
56
|
+
| `sumac/passphrase.py` | env-then-prompt resolution, key caching within a process |
|
|
57
|
+
| `sumac/paths.py` | Data-dir layout; the single place path names are constructed |
|
|
58
|
+
| `sumac/store.py` | Encrypted JSONL append/iterate over a `stream_id` |
|
|
59
|
+
| `sumac/config.py` | Location layout on top of `store` |
|
|
60
|
+
| `sumac/ledger.py` | Fold snapshots + changes → current inventory |
|
|
61
|
+
| `sumac/render.py` | rich tables/panels — kept out of `cli.py` so command logic stays testable |
|
|
62
|
+
| `sumac/cli.py` | Typer app |
|
|
63
|
+
| `sumac/errors.py` | Exception hierarchy the CLI maps to exit codes |
|
|
64
|
+
|
|
65
|
+
## Data model (`models.py`)
|
|
66
|
+
|
|
67
|
+
- `Location(id, name, parent_id, metadata)` — flat with optional parent, so sublocations nest arbitrarily.
|
|
68
|
+
- `Product(id, name, unit, category, metadata)`
|
|
69
|
+
- `Quantity(amount: Decimal, unit: str)` — mismatched units raise rather than silently coerce.
|
|
70
|
+
- `ChangeKind` enum: `purchase | consumption | waste | discovery | correction | movement`.
|
|
71
|
+
- `InventoryChange(..., product_id, quantity, from_location, to_location, ...)` — a delta or a transfer.
|
|
72
|
+
- `SnapshotEntry(product_id, quantity, metadata)`; `InventorySnapshot(location_id, entries, ...)` — the
|
|
73
|
+
full observed state of one location at one time.
|
|
74
|
+
- `Record(schema_version, type, id, ts, actor, supersedes, payload)` — the envelope on every JSONL line.
|
|
75
|
+
|
|
76
|
+
`metadata: Mapping[str, JsonValue]` on products, changes, snapshots and snapshot entries carries seller- or
|
|
77
|
+
user-supplied extras beyond the core model; validated as JSON-serialisable at ingest, otherwise untouched.
|
|
78
|
+
|
|
79
|
+
Corrections never rewrite: a new record carries `supersedes: <record-id>`.
|
|
80
|
+
|
|
81
|
+
## Ledger semantics (`ledger.py`)
|
|
82
|
+
|
|
83
|
+
1. Read all logs, drop any record id named by a `supersedes` field.
|
|
84
|
+
2. Order by `(ts, actor, id)` for determinism across machines.
|
|
85
|
+
3. Baseline per location = its most recent snapshot at or before the query time; a snapshot **resets** that
|
|
86
|
+
location's products rather than merging into them.
|
|
87
|
+
4. Apply changes after that snapshot; `movement` applies a negative delta at `from_location` and a positive
|
|
88
|
+
one at `to_location`.
|
|
89
|
+
|
|
90
|
+
## Ownership and versioning
|
|
91
|
+
|
|
92
|
+
- `store.append()` refuses any `stream_id` other than the current user's — with the AAD binding above, that
|
|
93
|
+
is the mechanism, and `docs/LAYOUT.md` documents which paths are read-only by convention.
|
|
94
|
+
- `sumac verify` re-opens every line of every log under its own AAD and reports lines that fail plus records
|
|
95
|
+
whose `actor` disagrees with the owning file.
|
|
96
|
+
- `SCHEMA_VERSION` on every record; readers reject records from a newer schema with an "upgrade sumac" error.
|
|
97
|
+
- `.gitattributes`: `merge=union` on `data/**/*.jsonl` — correct for append-only streams and it keeps
|
|
98
|
+
concurrent pushes from conflicting.
|
|
99
|
+
|
|
100
|
+
## CLI
|
|
101
|
+
|
|
102
|
+
`init`, `config show|add-location`, `add` (a change), `snapshot`, `status [location]`, `find <product>`,
|
|
103
|
+
`log`, `verify`. Rendering lives in `render.py`.
|
|
104
|
+
|
|
105
|
+
## Tooling
|
|
106
|
+
|
|
107
|
+
- `pyproject.toml` (uv, `uv.lock` committed): `pynacl`, `pydantic>=2`, `rich`, `typer`; dev group `ruff`,
|
|
108
|
+
`ty`, `pytest`. PyNaCl for XChaCha20-Poly1305 — `cryptography` only ships the 12-byte-nonce variant.
|
|
109
|
+
- `.claude/settings.json`: `PostToolUse` hook on `Edit|Write` running `uv run ruff format` on edited `.py`.
|
|
110
|
+
- `.github/workflows/ci.yml`: `uv sync` → `ruff format --check` → `ruff check` → `ty check` → `pytest`.
|
|
111
|
+
- Docs: `README.md` (usage), `docs/FORMAT.md` (on-disk format + threat model), `docs/LAYOUT.md`
|
|
112
|
+
(read-only vs mutable). Concise; no narration.
|
|
113
|
+
|
|
114
|
+
## Build order
|
|
115
|
+
|
|
116
|
+
1. Scaffolding: `pyproject.toml`, ruff/ty config, CI, Claude hook, doc skeletons.
|
|
117
|
+
2. `models.py` + `schemas.py` + tests.
|
|
118
|
+
3. `crypto.py`, `passphrase.py`, `paths.py`, `store.py` + tests.
|
|
119
|
+
4. `config.py` + `ledger.py` + tests.
|
|
120
|
+
5. `cli.py` + `render.py`, docs filled in.
|
|
121
|
+
|
|
122
|
+
## Verification
|
|
123
|
+
|
|
124
|
+
Tests: crypto round-trip; wrong passphrase fails at the verifier; a line moved between streams fails to
|
|
125
|
+
open; `append` rejects a foreign `stream_id`; ledger cases (snapshot reset, movement, supersede, unit
|
|
126
|
+
mismatch); config latest-revision-wins; and a leak test walking `data/` asserting every path component is a
|
|
127
|
+
fixed literal or a known username.
|
|
128
|
+
|
|
129
|
+
End-to-end, against a scratch data dir:
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
SUMAC_PASSPHRASE=test uv run sumac init
|
|
133
|
+
… add-location, add, snapshot, status, verify
|
|
134
|
+
grep -r "pantry\|fridge" data/ # must find nothing
|
|
135
|
+
uv run ruff format --check . && uv run ruff check . && uv run ty check && uv run pytest
|
|
136
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
Make a Python app with encrypted event store to log the inventory of a home’s groceries in different locations.
|
|
2
|
+
|
|
3
|
+
Make it fully generic, reading the config for the locations from a file that is encrypted.
|
|
4
|
+
|
|
5
|
+
Encryption is with a universal passphrase that can be shared between users of the app. The goal of
|
|
6
|
+
encryption is for the grocery records to be encrypted at rest (the config of the location layout and the content of groceries at those locations).
|
|
7
|
+
The app must not leak these secrets through any files it creates (e.g. by using location names as
|
|
8
|
+
folder names).
|
|
9
|
+
|
|
10
|
+
The form of encryption will be per-line AEAD: each JSONL record is independently encrypted (XChaCha20-Poly1305, random nonce per line, base64'd), so appending a line is a byte-append and git packs it well. This costs you per-line ciphertext overhead and leaks record count and approximate size, but we accept that.
|
|
11
|
+
|
|
12
|
+
The users of the app will pull down the app and update the mutable parts.
|
|
13
|
+
|
|
14
|
+
The app users should expect some parts of the app to be read only and some parts to be mutable, and the app itself should be versioned.
|
|
15
|
+
|
|
16
|
+
Users cannot mutate another user's historical records; they correct or supersede them by appending new records.
|
|
17
|
+
|
|
18
|
+
Note that the read-only-ness is a convention and not achieved by file permissions. The name of the user can be obtained from the OS (`getpass.getuser()` in Python).
|
|
19
|
+
|
|
20
|
+
The datasets should be read-only for the given user, no user should be able to edit someone else’s data.
|
|
21
|
+
|
|
22
|
+
The program should be documented (but concisely, not narrated extensively). Use uv to manage the dependencies and include Claude Code hooks that tell an agent to run ruff format when editing the code.
|
|
23
|
+
|
|
24
|
+
Keep the data model of the objects stored standalone in a module so that it can be easily edited.
|
|
25
|
+
|
|
26
|
+
The product inventory should be able to carry extra metadata (such as might be provided by a grocery seller, or by users) beyond the core data model.
|
|
27
|
+
|
|
28
|
+
Opt for modules dedicated to each of the scopes of functionality, do not overburden any one module. Use Pydantic for validation at the boundaries where data is ingested, and frozen dataclasses elsewhere.
|
|
29
|
+
|
|
30
|
+
Use the rich library for the terminal app.
|
|
31
|
+
|
|
32
|
+
Use ty in a GitHub CI check on the software.
|
|
33
|
+
|
|
34
|
+
Use JSONL for the format of inventory logging, users will write that (users being the people living there).
|
|
35
|
+
|
|
36
|
+
Model inventory logging around two primitives: inventory snapshots and inventory changes.
|
|
37
|
+
|
|
38
|
+
A snapshot records the observed quantities of items at a particular location/sublocation at a specific point in time.
|
|
39
|
+
|
|
40
|
+
A change records a delta or transfer to/from inventory (e.g. purchase, consumption, waste, discovery, correction, or movement between locations).
|
|
41
|
+
|
|
42
|
+
Treat changes as the normal operational input and snapshots as explicit observations/reconciliation points.
|
|
43
|
+
|
|
44
|
+
The current inventory should be derivable from a snapshot plus subsequent changes.
|
|
45
|
+
|
|
46
|
+
Write concisely and avoid ‘slop’.
|
|
47
|
+
|
|
48
|
+
Initially, create a plan, do not go all in
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sumac-home"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Home grocery inventory app"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"sealedlog>=0.1.0",
|
|
9
|
+
"pydantic>=2",
|
|
10
|
+
"rich>=13",
|
|
11
|
+
"typer>=0.12",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.scripts]
|
|
15
|
+
sumac = "sumac.cli:main"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/sumac"]
|
|
23
|
+
|
|
24
|
+
[dependency-groups]
|
|
25
|
+
dev = [
|
|
26
|
+
"pynacl>=1.5",
|
|
27
|
+
"ruff>=0.6",
|
|
28
|
+
"ty>=0.0.1a1",
|
|
29
|
+
"pytest>=8",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py312"
|
|
35
|
+
|
|
36
|
+
[tool.ruff.lint]
|
|
37
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|