Domain: sequenced journal with contiguity-contract reader

Issue:
Once the importer rejects a bad row, every row imported afterwards is lost: the writes return normally with no error, but the new entries never show up when the journal is read back — scans and tails behave as if the log ended just before the rejection. A journal that has never rejected anything reads back perfectly. The rejection itself is correct and expected for those rows; the problem is what happens to the valid rows committed after it. Expected behavior: rejecting an invalid record leaves the journal exactly as it was, and records accepted afterwards are fully visible to readers.

Repository files:
content: # Present so pytest adds the repository root to sys.path, making `import src`
# resolve from the test suite.

path: conftest.py

content: # Pure standard library unless the environment image pre-bakes wheels.

path: requirements.lock

path: src/__init__.py

content: """Wire format for journal entries.

An entry is a single line: ``kind|body``. Records that cannot be represented
on one line are rejected before they reach the journal.
"""

from .models import Record


def encode(record):
    """Serialize a record to its entry payload.

    Raises ValueError for records that must not enter the journal: an empty
    or non-alphanumeric kind, an empty body, or a body containing the field
    separator or a newline.
    """
    if not record.kind or not record.kind.isalnum():
        raise ValueError("record kind must be non-empty and alphanumeric")
    if not record.body:
        raise ValueError("record body must be non-empty")
    if "|" in record.body or "\n" in record.body:
        raise ValueError("record body must not contain '|' or newlines")
    return "{}|{}".format(record.kind, record.body)


def decode(payload):
    """Parse an entry payload back into a Record."""
    kind, body = payload.split("|", 1)
    return Record(kind, body)

path: src/codec.py

content: """Plain-text rendering of journal records."""


def to_lines(records):
    """Format records as 'kind: body' lines."""
    return ["{}: {}".format(record.kind, record.body) for record in records]


def to_text(records):
    """Render records as a newline-joined document."""
    return "\n".join(to_lines(records))

path: src/export.py

content: """Append-only, in-memory journal of encoded records."""

from .codec import encode


class Journal:
    """Stores entry payloads keyed by their sequence number, starting at 1."""

    def __init__(self):
        self._entries = {}
        self._next_seq = 1

    @classmethod
    def from_entries(cls, entries, next_seq):
        """Restore a journal from a snapshot of {seq: payload}."""
        journal = cls()
        journal._entries = dict(entries)
        journal._next_seq = next_seq
        return journal

    def append(self, record):
        """Append a record and return its assigned sequence number."""
        seq = self._next_seq
        self._next_seq += 1
        payload = encode(record)
        self._entries[seq] = payload
        return seq

    def get(self, seq):
        """Return the entry payload stored at seq."""
        return self._entries[seq]

    def __contains__(self, seq):
        return seq in self._entries

    def __len__(self):
        return len(self._entries)

path: src/journal.py

content: """Data model for journal records."""

from dataclasses import dataclass


@dataclass(frozen=True)
class Record:
    """A single record to be committed to the journal."""

    kind: str
    body: str

path: src/models.py

content: """Read-side access to a journal."""

from .codec import decode


def scan(journal):
    """Decode the committed records in sequence order.

    Entries are contiguous: the scan walks sequence numbers from 1 upward,
    and the first missing number marks the end of the committed log.
    """
    records = []
    seq = 1
    while seq in journal:
        records.append(decode(journal.get(seq)))
        seq += 1
    return records


def tail(journal, n):
    """The last n committed records, oldest of them first."""
    records = scan(journal)
    if n <= 0:
        return []
    return records[-n:]

path: src/reader.py

content: """Tests for the journal, codec, reader, and export."""

import pytest

from src.codec import decode, encode
from src.export import to_lines, to_text
from src.journal import Journal
from src.models import Record
from src.reader import scan, tail


def test_append_and_scan_round_trip():
    journal = Journal()
    seqs = [
        journal.append(Record("note", "first")),
        journal.append(Record("alert", "second")),
        journal.append(Record("note", "third")),
    ]
    assert seqs == [1, 2, 3]
    assert [record.body for record in scan(journal)] == ["first", "second", "third"]


def test_append_rejects_invalid_records():
    journal = Journal()
    with pytest.raises(ValueError):
        journal.append(Record("", "no kind"))
    with pytest.raises(ValueError):
        journal.append(Record("note", "bad|body"))
    with pytest.raises(ValueError):
        journal.append(Record("note", ""))
    assert len(journal) == 0


def test_scan_stops_at_snapshot_gap():
    journal = Journal.from_entries(
        {1: encode(Record("note", "kept")), 3: encode(Record("note", "orphan"))},
        next_seq=4,
    )
    assert [record.body for record in scan(journal)] == ["kept"]


def test_codec_round_trip_and_validation():
    payload = encode(Record("alert", "disk almost full"))
    assert payload == "alert|disk almost full"
    assert decode(payload) == Record("alert", "disk almost full")
    with pytest.raises(ValueError):
        encode(Record("bad kind", "body"))


def test_tail_returns_latest():
    journal = Journal()
    for body in ("a", "b", "c"):
        journal.append(Record("note", body))
    assert [record.body for record in tail(journal, 2)] == ["b", "c"]
    assert tail(journal, 0) == []


def test_export_formats_records():
    records = [Record("note", "hello"), Record("alert", "disk almost full")]
    assert to_lines(records) == ["note: hello", "alert: disk almost full"]
    assert to_text(records) == "note: hello\nalert: disk almost full"

path: tests/public/test_public.py

Reference patch:
--- a/src/journal.py
+++ b/src/journal.py
@@ -20,9 +20,9 @@ class Journal:
 
     def append(self, record):
         """Append a record and return its assigned sequence number."""
+        payload = encode(record)
         seq = self._next_seq
         self._next_seq += 1
-        payload = encode(record)
         self._entries[seq] = payload
         return seq
 


Hidden acceptance tests (diff):
diff --git a/tests/hidden/test_hidden.py b/tests/hidden/test_hidden.py
new file mode 100644
--- /dev/null
+++ b/tests/hidden/test_hidden.py
@@ -0,0 +1,44 @@
+"""Hidden evaluation tests. Never agent-visible (DEC-015).
+
+FAIL_TO_PASS: on the buggy fixture, append() reserves a sequence number
+before validation, so a rejected record burns a number and leaves a gap;
+the reader's documented contiguity contract then hides every record
+committed after the rejection. All three tests fail on the buggy fixture
+and pass once rejected records no longer consume sequence numbers.
+"""
+
+import pytest
+
+from src.journal import Journal
+from src.models import Record
+from src.reader import scan
+
+
+def test_records_after_a_rejection_remain_visible():
+    journal = Journal()
+    journal.append(Record("note", "before"))
+    with pytest.raises(ValueError):
+        journal.append(Record("note", "bad|body"))
+    journal.append(Record("note", "after"))
+    assert [record.body for record in scan(journal)] == ["before", "after"]
+
+
+def test_rejected_records_do_not_consume_sequence_numbers():
+    journal = Journal()
+    assert journal.append(Record("note", "first")) == 1
+    with pytest.raises(ValueError):
+        journal.append(Record("", "no kind"))
+    assert journal.append(Record("note", "second")) == 2
+
+
+def test_interleaved_rejections_keep_the_log_complete():
+    journal = Journal()
+    journal.append(Record("note", "a"))
+    for bad in (Record("note", "x|y"), Record("note", "")):
+        with pytest.raises(ValueError):
+            journal.append(bad)
+    journal.append(Record("alert", "b"))
+    journal.append(Record("note", "c"))
+    records = scan(journal)
+    assert [record.body for record in records] == ["a", "b", "c"]
+    assert len(journal) == 3