From 38e83a33e46bf9c56ada8cb31b4efe13a47884aa Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 28 Jun 2026 10:45:21 -0600 Subject: [PATCH] feat(contract): relax `raw` to producer invariant, off the screen+audio floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen-jsonl and audio-jsonl at-rest contracts marked the producer-specific `raw` header field as universally `required` on their shared floor. A terminal/tmux screen observer with no source media legitimately emits no `raw` and was rejected `422 ingest_contract_invalid` and quarantined; the audio side carried the same latent trap. Drop `raw` from `$defs.header.required` in both schemas (it stays in `properties` so a present value type-checks, and in `x-journal-contract.key_fields` so the breaking-change classifier nets to zero). `raw` is now a producer-owned invariant — emitted by the screen describer and audio transcriber and pinned by producer tests — not a shared-floor requirement. Regenerate the committed bundle. Document the floor model in the contract module and add the journal-format-contract-maintenance playbook (adding a new-format observer; the forward-compat governing principle). Add coverage: producer-invariant red-fail guards, no-raw at-rest + ingest acceptance for both families, a non-raw floor violation still rejecting, an old-floor check confirming the fixtures failed only on `raw`, and no-silent-skip selector coverage. The no-raw screen/audio fixtures are faithful synthetics (the real quarantined suze artifact could not be safely pulled from the production host). Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/observer/tests/test_routes.py | 86 ++++++++++++ solstone/observe/screen.schema.json | 2 +- solstone/observe/transcribe/audio.schema.json | 2 +- solstone/talent/journal/contract/bundle.json | 8 +- solstone/think/contract/journal.py | 17 ++- tests/fixtures/contract/README.md | 8 ++ .../contract/external_audio_no_raw.jsonl | 2 + .../contract/tmux_screen_no_raw.jsonl | 3 + tests/test_describe_promote.py | 18 +++ tests/test_journal_contract.py | 131 ++++++++++++++++++ tests/test_transcribe.py | 14 ++ .../journal-format-contract-maintenance.md | 29 ++++ 12 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/contract/README.md create mode 100644 tests/fixtures/contract/external_audio_no_raw.jsonl create mode 100644 tests/fixtures/contract/tmux_screen_no_raw.jsonl create mode 100644 vpe/playbooks/journal-format-contract-maintenance.md diff --git a/solstone/apps/observer/tests/test_routes.py b/solstone/apps/observer/tests/test_routes.py index 2be91dfc5..1c006c26a 100644 --- a/solstone/apps/observer/tests/test_routes.py +++ b/solstone/apps/observer/tests/test_routes.py @@ -2720,6 +2720,92 @@ def test_ingest_contract_sidecars_valid_are_accepted(observer_env, monkeypatch): assert len(emitted) == 1 +def test_ingest_contract_sidecars_without_raw_are_accepted(observer_env, monkeypatch): + env = observer_env() + emitted = [] + monkeypatch.setattr( + routes_module, + "emit", + lambda tract, event, **fields: emitted.append((tract, event, fields)), + ) + + resp = env.client.post( + "/app/observer/api/create", + json={"name": "contract-no-raw-test"}, + content_type="application/json", + ) + key = resp.get_json()["key"] + + audio = b'{"observer":"external"}\n{"start":"00:00:00","text":"hi"}\n' + screen = b'{"observer":"tmux"}\n{"timestamp":1.0}\n' + resp = env.client.post( + "/app/observer/ingest", + headers={"Authorization": f"Bearer {key}"}, + data={ + "day": "20250103", + "segment": "120000_300", + "files": [ + (io.BytesIO(audio), "120000_300_audio.jsonl"), + (io.BytesIO(screen), "screen.jsonl"), + ], + }, + ) + + body = resp.get_json() + assert resp.status_code == 200 + assert body["status"] == "ok" + assert body["files"] == ["audio.jsonl", "screen.jsonl"] + assert len(emitted) == 1 + segment_dir = _day_dir(env) / "contract-no-raw-test" / "120000_300" + assert (segment_dir / "audio.jsonl").read_bytes() == audio + assert (segment_dir / "screen.jsonl").read_bytes() == screen + assert not (_day_dir(env) / "observer" / "failed").exists() + + +def test_ingest_contract_screen_floor_violation_quarantined_without_emit( + observer_env, monkeypatch +): + env = observer_env() + emitted = [] + monkeypatch.setattr( + routes_module, + "emit", + lambda tract, event, **fields: emitted.append((tract, event, fields)), + ) + + resp = env.client.post( + "/app/observer/api/create", + json={"name": "contract-screen-invalid-test"}, + content_type="application/json", + ) + key = resp.get_json()["key"] + + invalid_screen = b'{"observer":"tmux"}\n{"content":{}}\n' + resp = env.client.post( + "/app/observer/ingest", + headers={"Authorization": f"Bearer {key}"}, + data={ + "day": "20250103", + "segment": "120000_300", + "files": (io.BytesIO(invalid_screen), "screen.jsonl"), + }, + ) + + body = resp.get_json() + assert resp.status_code == 422 + assert body["status"] == "failed" + assert body["reason_code"] == "ingest_contract_invalid" + assert any( + "screen.jsonl" in item and "timestamp" in item for item in body["invalid_files"] + ) + assert emitted == [] + + assert not (_day_dir(env) / "contract-screen-invalid-test" / "120000_300").exists() + failed_dir = env.journal / "chronicle" / body["failed_path"] + assert failed_dir.exists() + assert (failed_dir / "screen.jsonl").read_bytes() == invalid_screen + + def test_ingest_stream_qualifier_preserved(observer_env): """Regression: tmux observer must land in host.tmux, not host stream. diff --git a/solstone/observe/screen.schema.json b/solstone/observe/screen.schema.json index ad29f3db6..251266e7f 100644 --- a/solstone/observe/screen.schema.json +++ b/solstone/observe/screen.schema.json @@ -8,7 +8,7 @@ "header": { "type": "object", "additionalProperties": true, - "required": ["raw"], + "required": [], "properties": { "raw": {"type": "string"}, "observer": {"type": "string"}, diff --git a/solstone/observe/transcribe/audio.schema.json b/solstone/observe/transcribe/audio.schema.json index 6f3b8c88f..902e96201 100644 --- a/solstone/observe/transcribe/audio.schema.json +++ b/solstone/observe/transcribe/audio.schema.json @@ -8,7 +8,7 @@ "header": { "type": "object", "additionalProperties": true, - "required": ["raw"], + "required": [], "properties": { "raw": {"type": "string"}, "observer": {"type": "string"}, diff --git a/solstone/talent/journal/contract/bundle.json b/solstone/talent/journal/contract/bundle.json index 0784b0862..b255bd3fe 100644 --- a/solstone/talent/journal/contract/bundle.json +++ b/solstone/talent/journal/contract/bundle.json @@ -89,9 +89,7 @@ "type": "string" } }, - "required": [ - "raw" - ], + "required": [], "type": "object" }, "record": { @@ -284,9 +282,7 @@ "type": "string" } }, - "required": [ - "raw" - ], + "required": [], "type": "object" }, "record": { diff --git a/solstone/think/contract/journal.py b/solstone/think/contract/journal.py index 1f0b529b8..ff18fee2d 100644 --- a/solstone/think/contract/journal.py +++ b/solstone/think/contract/journal.py @@ -1,7 +1,22 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Build and validate the journal at-rest contract bundle.""" +"""Build and validate the journal at-rest contract bundle. + +Floor model +----------- +Each schema's ``$defs.header`` and ``$defs.record`` ``required`` arrays define +the universal at-rest floor every producer of that format must meet. ``raw`` is +not part of that floor. It is a producer-owned invariant emitted by the screen +describer (``solstone.observe.describe.VideoProcessor``) and the audio +transcriber (``solstone.observe.transcribe.main``), pinned by producer tests +rather than the shared floor. + +Producers with no source media, such as a terminal/tmux observer, legitimately +omit ``raw`` and must still validate. ``raw`` remains in each schema's +``properties`` so a present value must type-check as a string, and it remains in +``key_fields``, but it is no longer ``required``. +""" from __future__ import annotations diff --git a/tests/fixtures/contract/README.md b/tests/fixtures/contract/README.md new file mode 100644 index 000000000..def875b73 --- /dev/null +++ b/tests/fixtures/contract/README.md @@ -0,0 +1,8 @@ +# Contract Fixtures + +These are faithful synthetics standing in for the real suze tmux +`chronicle/*/observer/failed/*screen.jsonl` artifact, which could not be pulled +from production safely. + +The tmux observer shape has a header with no `raw` because there is no source +media. Its records carry `timestamp`, matching the screen record floor. diff --git a/tests/fixtures/contract/external_audio_no_raw.jsonl b/tests/fixtures/contract/external_audio_no_raw.jsonl new file mode 100644 index 000000000..a44a9c189 --- /dev/null +++ b/tests/fixtures/contract/external_audio_no_raw.jsonl @@ -0,0 +1,2 @@ +{"observer":"external","backend":"whisper"} +{"start":"00:00:00","text":"hello"} diff --git a/tests/fixtures/contract/tmux_screen_no_raw.jsonl b/tests/fixtures/contract/tmux_screen_no_raw.jsonl new file mode 100644 index 000000000..914fe0e63 --- /dev/null +++ b/tests/fixtures/contract/tmux_screen_no_raw.jsonl @@ -0,0 +1,3 @@ +{"observer":"tmux"} +{"timestamp":1.0,"content":{"text":"$ ls"}} +{"timestamp":2.0,"content":{"text":"README.md"}} diff --git a/tests/test_describe_promote.py b/tests/test_describe_promote.py index 918b576fe..c875878e5 100644 --- a/tests/test_describe_promote.py +++ b/tests/test_describe_promote.py @@ -96,6 +96,24 @@ def test_build_metadata_header_includes_static_single_frame_hash(tmp_path, monke } +def test_describe_header_raw_is_producer_invariant(tmp_path, monkeypatch): + video_path = _video_path(tmp_path) + processor = describe_module.VideoProcessor.__new__(describe_module.VideoProcessor) + processor.video_path = video_path + processor.first_hash = None + processor.last_hash = None + processor.qualified_count = 1 + monkeypatch.delenv("OBSERVER_NAME", raising=False) + monkeypatch.delenv("SEGMENT_META", raising=False) + + header = processor._build_metadata_header() + + # raw is the producer's invariant (relaxed from the shared floor), so the + # describer must keep emitting it. + assert "raw" in header + assert header["raw"] == video_path.name + + class FakeBatch: instances = [] outcomes = {} diff --git a/tests/test_journal_contract.py b/tests/test_journal_contract.py index 067dcc09d..7501b53f5 100644 --- a/tests/test_journal_contract.py +++ b/tests/test_journal_contract.py @@ -12,6 +12,10 @@ from solstone.think.contract import journal from solstone.think.journal_io.migrate import locked_rewrite_jsonl, rewrite_json +def _contract_fixture(name: str) -> bytes: + return (journal.ROOT / "tests" / "fixtures" / "contract" / name).read_bytes() + + def test_journal_contract_bundle_discovers_writer_adjacent_schemas() -> None: bundle = journal.build_bundle() formats = set(bundle["schemas"]) @@ -44,6 +48,133 @@ def test_contract_validator_accepts_audio_jsonl_and_reports_missing_text() -> No assert any("'text' is a required property" in issue.message for issue in issues) +def test_contract_validator_accepts_screen_no_raw_fixture() -> None: + bundle = journal.build_bundle() + schema = bundle["schemas"]["screen-jsonl"]["schema"] + + issues = journal.validate_contract_file( + "screen.jsonl", + _contract_fixture("tmux_screen_no_raw.jsonl"), + schema, + ) + + assert issues == [] + + +def test_contract_validator_accepts_audio_no_raw_fixture() -> None: + bundle = journal.build_bundle() + schema = bundle["schemas"]["audio-jsonl"]["schema"] + + issues = journal.validate_contract_file( + "audio.jsonl", + _contract_fixture("external_audio_no_raw.jsonl"), + schema, + ) + + assert issues == [] + + +def test_contract_validator_accepts_producer_headers_with_raw() -> None: + bundle = journal.build_bundle() + screen_schema = bundle["schemas"]["screen-jsonl"]["schema"] + audio_schema = bundle["schemas"]["audio-jsonl"]["schema"] + + screen = b'{"raw":"screen.webm","observer":"desk"}\n{"timestamp":1.0}\n' + audio = b'{"raw":"audio.flac","observer":"mic"}\n{"start":"00:00:00","text":"hi"}\n' + + assert journal.validate_contract_file("screen.jsonl", screen, screen_schema) == [] + assert journal.validate_contract_file("audio.jsonl", audio, audio_schema) == [] + + +def test_contract_validator_still_rejects_non_raw_floor_violations() -> None: + bundle = journal.build_bundle() + screen_schema = bundle["schemas"]["screen-jsonl"]["schema"] + audio_schema = bundle["schemas"]["audio-jsonl"]["schema"] + + screen_issues = journal.validate_contract_file( + "screen.jsonl", + b'{"observer":"tmux"}\n{"content":{}}\n', + screen_schema, + ) + audio_issues = journal.validate_contract_file( + "audio.jsonl", + b'{"observer":"external"}\n{"start":"00:00:00"}\n', + audio_schema, + ) + + assert any("timestamp" in issue.message for issue in screen_issues) + assert any("text" in issue.message for issue in audio_issues) + + +def test_old_floor_no_raw_fixtures_failed_only_on_raw() -> None: + bundle = journal.build_bundle() + screen_schema = copy.deepcopy(bundle["schemas"]["screen-jsonl"]["schema"]) + audio_schema = copy.deepcopy(bundle["schemas"]["audio-jsonl"]["schema"]) + screen_schema["$defs"]["header"]["required"] = ["raw"] + audio_schema["$defs"]["header"]["required"] = ["raw"] + + screen_issues = journal.validate_contract_file( + "screen.jsonl", + _contract_fixture("tmux_screen_no_raw.jsonl"), + screen_schema, + ) + audio_issues = journal.validate_contract_file( + "audio.jsonl", + _contract_fixture("external_audio_no_raw.jsonl"), + audio_schema, + ) + + assert len(screen_issues) == 1 + assert "raw" in screen_issues[0].message + assert "required" in screen_issues[0].message + assert len(audio_issues) == 1 + assert "raw" in audio_issues[0].message + assert "required" in audio_issues[0].message + + +def test_validate_journal_tree_accepts_no_raw_at_rest_files(tmp_path) -> None: + segment = tmp_path / "chronicle" / "20260601" / "tmux" / "093000_300" + segment.mkdir(parents=True) + (segment / "screen.jsonl").write_bytes( + _contract_fixture("tmux_screen_no_raw.jsonl") + ) + (segment / "audio.jsonl").write_bytes( + _contract_fixture("external_audio_no_raw.jsonl") + ) + + raw_segment = tmp_path / "chronicle" / "20260601" / "tmux" / "093500_300" + raw_segment.mkdir(parents=True) + (raw_segment / "screen.jsonl").write_bytes( + b'{"raw":"screen.webm","observer":"desk"}\n{"timestamp":1.0}\n' + ) + + assert journal.validate_journal_tree(tmp_path, journal.build_bundle()) == [] + + +def test_schema_for_filename_selects_screen_and_audio_sidecars() -> None: + bundle = journal.build_bundle() + + for filename in ( + "screen.jsonl", + "audio.jsonl", + "123456_screen.jsonl", + "src_audio.jsonl", + ): + assert journal.schema_for_filename(filename, bundle) is not None + + +def test_journal_contract_docs_cover_floor_and_maintenance_playbook() -> None: + playbook = ( + journal.ROOT / "vpe" / "playbooks" / "journal-format-contract-maintenance.md" + ).read_text(encoding="utf-8") + + assert "## Adding an observer with a new format" in playbook + assert "## Forward-compatibility governing principle" in playbook + assert journal.__doc__ is not None + assert "Floor model" in journal.__doc__ + assert "producer-owned invariant" in journal.__doc__ + + def test_contract_breaking_change_tripwire_flags_removed_key_fields() -> None: committed = journal.build_bundle() current = copy.deepcopy(committed) diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index fa0a8cb77..fd00cc062 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -709,6 +709,20 @@ class TestJSONLFormat: assert metadata["duration"] == 12.34 assert isinstance(metadata["duration"], float) + def test_statements_to_jsonl_raw_is_producer_invariant(self): + lines = _statements_to_jsonl( + [{"start": 1.0, "end": 2.0, "text": "Hello"}], + "audio.flac", + datetime(2026, 5, 22, 9, 0, 0), + {"model": "unit", "device": "cpu", "compute_type": "int8"}, + ) + + metadata = json.loads(lines[0]) + + # raw is the producer's invariant (relaxed from the shared floor), so the + # transcriber must keep emitting it. + assert metadata["raw"] == "audio.flac" + def test_metadata_first_line(self): """First line should be metadata with 'raw' field.""" lines = [ diff --git a/vpe/playbooks/journal-format-contract-maintenance.md b/vpe/playbooks/journal-format-contract-maintenance.md new file mode 100644 index 000000000..85b54dda6 --- /dev/null +++ b/vpe/playbooks/journal-format-contract-maintenance.md @@ -0,0 +1,29 @@ +# Journal Format Contract Maintenance + +## Adding an observer with a new format + +Define a new schema whose floor, the `$defs.header` and `$defs.record` +`required` arrays, captures only what every producer of that format can meet. +Put producer-specific requirements such as `raw` at the producer: the writer +code should emit the field, and a producer test should pin that invariant. +Never put producer-specific requirements in the shared floor. + +Register the schema, then run: + +```bash +make contract +make check-contract +``` + +## Forward-compatibility governing principle + +The ingest contract is a published interface consumed by native observers. +Adding a `required` field is an intentional, deliberately-made, documented +breaking change because it rejects existing producers. It requires a forward +maintenance migration or a coordinated producer upgrade. There is no +version-negotiation layer. + +Relaxing the floor by removing a `required` field is forward-compatible and +safe. This lode relaxed `raw` from the floor for exactly this reason: producers +with no source media can legitimately omit it while producers that own `raw` +continue to pin it locally. -- 2.51.2