Metadata-Version: 2.5
Name: nodii-usage
Version: 0.4.0
Summary: Nodii usage library — fail-closed STOP check over a local projection, the meter and the one usage-metered payload (polyglot; TS @nodii/usage is the contract).
Project-URL: Homepage, https://github.com/cognion-nucleus/nodii-libs
Project-URL: Repository, https://github.com/cognion-nucleus/nodii-libs
Author-email: Nodii <ops@nodii.co>
License: MIT
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.11
Requires-Dist: nodii-db-rls<1.0.0,>=0.11.0
Requires-Dist: nodii-notify<1.0.0,>=0.1.0
Requires-Dist: nodii-outbox-dispatcher<1.0.0,>=0.7.0
Requires-Dist: nodii-replica-consumer<1.0.0,>=0.40.0
Description-Content-Type: text/markdown

# nodii-usage

Python port of `@nodii/usage` (S08-39, LIB-EVT-22): the STOP check, the meter and
the one usage-metered payload. A producer never holds a price and never names a
payer.

```python
from nodii_usage import OtherUnit, UsageConfig, define_usage

usage = define_usage(
    UsageConfig(
        source_service="nodii-geo-service",
        topic_prefix="geo",
        outbox_table="geo_outbox",
        pool=pool,  # asyncpg.Pool
        slice_keys=("geo.geocode_lookup.google",),
        projection_ready=lambda: snapshot_drained,
    )
)

decision = await usage.check_stop(tenant_id, "geo.geocode_lookup.google")
if decision.status != "allowed":
    ...  # `unavailable` is a refusal too: retry later, or suppress a broadcast

async with conn.transaction():
    await usage.meter(conn, OtherUnit(...))
```

- `check_stop` fails closed: any failure returns `status="unavailable"`, it never
  raises.
- `apply_usage_denial` is the one write path. SetUsageControl, the event and the
  snapshot all go through it, and the highest source version wins.
- `define_usage_denial_replica` gives the replica spec; run
  `get_usage_migration_sql()` for the projection and marker tables.

`keys_gen.py` is generated; never edit it.

```bash
uv run pytest python/usage/tests/
```

## Known limit: widening the slice

The projection stores only the denials inside the service's slice, and one version of a tenant's denials is applied once. A service that adds a key to its slice therefore reads that key as allowed, for a tenant already denied on it, until tenant-service emits that tenant's next version. A redelivery or a snapshot of the same version does not rewrite the row. Because the digest covers only the slice (so the SetUsageControl RPC, which carries only the target's slice, and the event, which carries every denial, agree at one version), a same-version redelivery that the wider slice now sees more of is read as a payload mismatch and quarantined, and so is every later replay of that version; the row is unchanged, and the tenant's next version applies normally. The fix is an open question on STANDARDIZATIONS-AUDIT-19.

## Upgrading from 0.2.0

Rows written before this release hold a digest of the whole message, while this release digests only the slice. Run the one-time repair once, as its own migration, after the rollout: `get_usage_digest_repair_sql()` returns an idempotent `UPDATE` that rewrites every row's digest from its stored denials (P-16). Without it, a tenant whose stored row came from a message with a denial outside the slice has every same-version replay quarantined as a payload mismatch until its next version; the stored denials stay correct either way. The repair rebuilds each digest from the row's stored denials, which are the slice the service used when the row was written. It assumes the slice has not changed since: if the service has narrowed its slice, a same-version replay still mismatches and is quarantined until the tenant's next version. Run it once the rollout has finished and no 0.2.0 consumer is still applying (a 0.2.0 pod that writes after the repair puts a whole-message digest back; the repair is idempotent, so re-running it after the rollout is safe). Run it as the table owner or `nodii_owner`, as migrations are: the projection has RLS, and a service role with no tenant set sees no rows, so the `UPDATE` would touch nothing. Check that the affected row count equals the table's row count.
