Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ All notable changes to `atomicmemory` will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.1.3] - 2026-09-22

### Fixed
- Encode explicit `content_class` on verbatim ingest in the v1 contract codec.
Text and messages modes still reject the field because the v1 wire contract
does not expose it on those modes. No content class is inferred or defaulted.
- Refresh the vendored contract schema and provenance to include the verbatim
content-class field.

### Security
- Refresh the development/source lockfile past the reported AnyIO, idna,
setuptools, Transformers, and Torch advisory ranges. This updates the locked
environment, not the SDK's dependency constraints; existing installations
should also update their dependencies.

## [1.1.2] - 2026-06-15

### Security
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ This is a Python port of the TypeScript [`atomicmemory-sdk`](https://github.com/

## Status

Stable release — `1.1.0` on [PyPI](https://pypi.org/project/atomicmemory/); `1.2.0` staged on main.
Stable releases are available on [PyPI](https://pypi.org/project/atomicmemory/).
This source tree prepares version `1.1.3`; consult PyPI for publication status.

## Installation

Expand Down
2 changes: 1 addition & 1 deletion atomicmemory/_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
__version__: The current package version string (PEP 440).
"""

__version__ = "1.1.2"
__version__ = "1.1.3"
33 changes: 19 additions & 14 deletions atomicmemory/contract/v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,11 +345,20 @@ def decode_search_request(wire: dict[str, Any]) -> SearchRequest:
def encode_ingest_input(model: IngestInput) -> dict[str, Any]:
"""Encode the in-process IngestInput model into the v1 wire form.

Routes ``provenance`` through :func:`encode_provenance`. Raises
``ValueError`` if ``content_class`` is set: the v1 schemas have
``additionalProperties: false`` with no ``content_class`` field, so
emitting it would be wire-invalid. This field is Python-ahead; the TS
contract catch-up is the recorded follow-up.
Routes ``provenance`` through :func:`encode_provenance`.

``content_class`` is emitted for ``mode="verbatim"``, which the v1
``VerbatimIngest`` schema now carries. It is REFUSED for ``text`` and
``messages``: those schemas remain ``additionalProperties: false`` without
the field, so emitting it would produce a wire-invalid payload. The Python
models carry ``content_class`` on ``IngestBase`` (every mode), so this
boundary is what keeps a valid in-process model from encoding into an
invalid request. Core's HTTP API does consult the field on extraction paths
for audit-transcript redaction — extending the contract to those modes is
tracked separately, since it changes what is durably retained.

This codec never infers a class: an omission stays an omission and fails
closed at core rather than being relabeled here as safe.

Args:
model: The in-process ingest input model (any mode variant).
Expand All @@ -358,18 +367,14 @@ def encode_ingest_input(model: IngestInput) -> dict[str, Any]:
A wire-format dict suitable for JSON serialization.

Raises:
ValueError: If the model carries ``content_class`` (Python-only field
not present in the v1 wire schema).
ValueError: If a non-verbatim ingest carries ``content_class``.
"""
# Deliberately generic (getattr, not an isinstance check on a single mode):
# content_class lives on IngestBase, so every ingest mode carries it and
# every mode must fail closed here until the v1 contract adds the field.
content_class = getattr(model, "content_class", None)
if content_class is not None:
if content_class is not None and getattr(model, "mode", None) != "verbatim":
raise ValueError(
f"content_class={content_class!r} is a Python-ahead field with no place in the v1 wire "
"schema (additionalProperties: false). Strip it before encoding, or wait for the TS "
"contract to add it."
f"content_class={content_class!r} is only valid on verbatim ingest in the v1 wire "
f"schema (mode={getattr(model, 'mode', None)!r} is additionalProperties: false "
"without it). Drop it, or use mode='verbatim'."
)
data = model.model_dump(mode="json", exclude_none=True)
if "provenance" in data and isinstance(data["provenance"], dict):
Expand Down
8 changes: 4 additions & 4 deletions contract/VENDORED.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"source_repo": "atomicmemory-internal",
"source_path": "packages/sdk/schema/v1 + packages/sdk/CONTRACT.md",
"source_sdk_version": "1.1.0",
"source_main_commit": "2a67871",
"schema_last_modified_commit": "6fccaf4",
"vendored_at": "2026-06-09"
"source_sdk_version": "1.1.1",
"source_main_commit": "67f58c9",
"schema_last_modified_commit": "46bc34c",
"vendored_at": "2026-07-29"
}
7 changes: 7 additions & 0 deletions contract/v1/provider-contract.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
"type": "string",
"enum": ["fact", "episode", "summary", "procedure", "document"]
},
"ContentClass": {
"title": "ContentClass",
"description": "Sensitivity class of supplied content: 'summary' (distilled, hosted-safe), 'redacted' (sensitive spans removed by the caller), or 'raw' (verbatim prompt/response/diff/source). This contract exposes it on verbatim ingest only; core's HTTP API also consults it on extraction paths, where it decides whether the raw transcript is withheld from the durable audit episode. A core running RAW_CONTENT_POLICY=reject (the default) refuses a verbatim write of 'raw' or unclassified content. Never inferred by a provider — the caller chooses it, so omitting it fails closed rather than mislabeling raw content as safe.",
"type": "string",
"enum": ["summary", "redacted", "raw"]
},
"Message": {
"title": "Message",
"type": "object",
Expand Down Expand Up @@ -104,6 +110,7 @@
"mode": { "const": "verbatim" },
"content": { "type": "string" },
"kind": { "$ref": "#/$defs/MemoryKind" },
"content_class": { "$ref": "#/$defs/ContentClass" },
"scope": { "$ref": "#/$defs/Scope" },
"provenance": { "$ref": "#/$defs/Provenance" },
"metadata": { "type": "object", "additionalProperties": true }
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "atomicmemory"
version = "1.1.2"
version = "1.1.3"
description = "Python client SDK for AtomicMemory memory and artifact storage."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
73 changes: 70 additions & 3 deletions tests/contract/test_codec.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Each test exercises a specific codec boundary: camel↔snake field renames,
``_to_iso_z`` millisecond precision, provenance nesting in Memory and ingest,
``rankingScore`` mapping, page round-trip, datetime-filter normalization, and
the ``content_class`` rejection guard.
``content_class`` emission on the verbatim path.
"""

from __future__ import annotations
Expand All @@ -17,10 +17,13 @@
from atomicmemory.memory.filters import FilterExpr
from atomicmemory.memory.types import (
Memory,
Message,
MessageIngest,
Provenance,
Scope,
SearchRequest,
SearchResultPage,
TextIngest,
VerbatimIngest,
)

Expand Down Expand Up @@ -163,12 +166,50 @@ def test_decoded_null_version_id_normalizes_to_absent_on_encode() -> None:
assert "version_id" not in v1.encode_search_result(result)


def test_encode_ingest_input_rejects_python_only_content_class() -> None:
def test_encode_ingest_input_emits_content_class() -> None:
# v1 VerbatimIngest carries content_class, so it must reach the wire: core
# refuses an unclassified verbatim write under RAW_CONTENT_POLICY=reject,
# and silently dropping the caller's stamp here would turn a correctly
# classified write into a 422.
model = VerbatimIngest(content="x", scope=Scope(user="u"), content_class="summary")
with pytest.raises(ValueError, match="content_class"):
assert v1.encode_ingest_input(model)["content_class"] == "summary"


def test_encode_ingest_input_rejects_content_class_on_text_ingest() -> None:
# The Python models carry content_class on IngestBase (every mode), but the
# v1 TextIngest schema is additionalProperties:false without it. Encoding it
# would emit a wire-invalid payload from a perfectly valid model, so the
# codec refuses at this boundary.
model = TextIngest(content="x", scope=Scope(user="u"), content_class="summary")
with pytest.raises(ValueError, match="only valid on verbatim"):
v1.encode_ingest_input(model)


def test_encode_ingest_input_rejects_content_class_on_messages_ingest() -> None:
model = MessageIngest(
messages=[Message(role="user", content="x")],
scope=Scope(user="u"),
content_class="summary",
)
with pytest.raises(ValueError, match="only valid on verbatim"):
v1.encode_ingest_input(model)


def test_encode_ingest_input_allows_non_verbatim_without_content_class() -> None:
# The guard is scoped to the field, not the mode: an ordinary text ingest
# still encodes.
encoded = v1.encode_ingest_input(TextIngest(content="x", scope=Scope(user="u")))
assert encoded["mode"] == "text"
assert "content_class" not in encoded


def test_encode_ingest_input_omits_absent_content_class() -> None:
# Absence stays absence — the codec never infers a class, so an unstamped
# write fails closed at core instead of being relabeled safe here.
model = VerbatimIngest(content="x", scope=Scope(user="u"))
assert "content_class" not in v1.encode_ingest_input(model)


def test_decode_memory_rejects_in_process_snake_date_key() -> None:
# The codec is a STRICT v1 boundary: in-process snake names are not wire
# names, and the rename-if-present pattern must not let them through.
Expand Down Expand Up @@ -205,3 +246,29 @@ def test_decode_search_request_is_passthrough() -> None:
assert model.query == "deploy gate"
assert model.limit == 5
assert model.scope.user == "u1"


def test_encoded_content_class_validates_against_the_v1_schema() -> None:
# The encoder emitting content_class is only correct if the wire schema
# accepts it: VerbatimIngest is additionalProperties:false, so a schema that
# had not been updated would reject the very payload the encoder produces.
# This pins encoder and schema together rather than trusting either alone.
from tests.contract._schema_registry import validator_for

encoded = v1.encode_ingest_input(VerbatimIngest(content="x", scope=Scope(user="u"), content_class="summary"))
validator_for("ingest-input.schema.json").validate(encoded)


def test_v1_schema_still_rejects_an_unknown_ingest_field() -> None:
# Guard the guard: prove additionalProperties:false is still doing work, so
# the test above passing means "content_class was added", not "the schema
# stopped constraining anything".
import jsonschema
import pytest as _pytest

from tests.contract._schema_registry import validator_for

encoded = v1.encode_ingest_input(VerbatimIngest(content="x", scope=Scope(user="u")))
encoded["not_a_real_field"] = "x"
with _pytest.raises(jsonschema.ValidationError):
validator_for("ingest-input.schema.json").validate(encoded)
12 changes: 9 additions & 3 deletions tests/contract/test_vendored_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,15 @@ def test_vendored_manifest_pins_the_exact_vendored_source() -> None:
vendored = json.loads((CONTRACT / "VENDORED.json").read_text())
assert set(vendored) >= REQUIRED_VENDORED_FIELDS
assert vendored["source_repo"] == "atomicmemory-internal"
assert vendored["source_sdk_version"] == "1.1.0"
assert vendored["source_main_commit"] == "2a67871"
assert vendored["schema_last_modified_commit"] == "6fccaf4"
assert vendored["source_sdk_version"] == "1.1.1"
# These two pins are INDEPENDENT and are copied from the manifest the
# refresh script generates, not set by hand: source_main_commit is the
# source checkout's HEAD, while schema_last_modified_commit is
# `git log -1 -- <vendored paths>`. They differ here exactly as expected —
# HEAD is the merge commit for atomicmemory-internal#76, while the schema
# was last touched by the commit that PR merged.
assert vendored["source_main_commit"] == "67f58c9"
assert vendored["schema_last_modified_commit"] == "46bc34c"


def test_corpus_manifest_cases_and_schemas_all_exist() -> None:
Expand Down
Loading
Loading