Skip to content

fix: write text as text in both readable forms instead of base64 - #46

Merged
konard merged 11 commits into
mainfrom
issue-45-f2100b3aab88
Aug 27, 2026
Merged

fix: write text as text in both readable forms instead of base64#46
konard merged 11 commits into
mainfrom
issue-45-f2100b3aab88

Conversation

@konard

@konard konard commented Aug 27, 2026

Copy link
Copy Markdown
Member

Closes #45.

The problem

encode_line (and encode) treated every control character as unwritable, so
one newline turned the whole string into base64:

(o: (message (base64 "R0VUIC9hcGkvdXNlcnMgLT4gNTAwCiAgYXQgaGFuZGxlcnMucnM6NDI=")))

The message, the stack trace and every word a reader would grep for went with
it. In a real log that is most of the text.

The doubled-quote escape used for a value holding " had the same effect for a
different reason: it desynchronises the notation's own parser, so the value came
back as something else.

Both are reproduced by the new suites before anything else changed.

The change

Applied identically to Rust, JavaScript, Python and C#:

  1. encode and encode_line never reach for base64. It stays reachable
    only through encode_compact() / encode_obfuscated(), which say so by name.
  2. Strings are written as text, with n-quote delimiters when the content
    holds the delimiter: """say "hi"""". The Rust suite proves the result reads
    back unchanged through links_notation::parse_lino_to_links.
  3. Raw newlines stay raw in encode.
  4. In encode_line only, the newline — and nothing else — is escaped:
    (escaped "line one%0Aline two"). The payload is percent-escaped, so even
    the escaped part stays readable and greppable.
  5. A value occurring more than once is written out every time. No shared
    reference, so a log line never depends on another line.
  6. The shared fixtures cover it: a multi-line string, a string holding the
    quote delimiter, a repeated value, a tab, a carriage return, a control
    character, a percent sign, a key holding a newline — 52 cases plus a legacy
    section, byte-identical across the four languages.

Which characters each form escapes:

character encode encode_line why
tab as is as is a reader can see it, and it ends nothing
newline as is %0A on one line it would end the record
carriage return %0D %0D CRLF normalisation would rewrite the value
other control characters %XX %XX not writable as text in either form

Fixed along the way

A key holding a control character used to be written as (base64 "…") in key
position
and read back as an array element, so {"a\nb": "a\nb"} silently
decoded to ["a\nb", "a\nb"]. Keys are now decoded as keys in all four
languages.

Backward compatibility

(base64 "…") is still decoded, and the two older quoting conventions (a run of
one delimiter meaning doubled-quote escaping, a run of two meaning the empty
value) still read. The legacy fixture section pins this in all four suites, so
every document written by an earlier version keeps decoding.

Reproducing and verifying

  • rust/tests/plain_text_values.rs, js/tests/test_plain_text_values.test.js,
    python/tests/test_plain_text_values.py,
    csharp/tests/.../PlainTextValuesTests.cs — the same 9–10 tests in each
    language, written against the behaviour the issue asks for, failing before the
    fix.
  • experiments/issue-45/quote-probe — the measurement behind quote(): which
    delimiter run each links-notation release actually reads back unchanged.
    experiments/issue-45/README.md records the result, including that the four
    packages pin different notation releases with different quoting rules (the
    readable format is read by this repository's own tokenizer in every language,
    so a value round-trips regardless).

All four suites pass locally: Rust 8 targets + clippy clean, JavaScript 497
tests + npm run check, Python 488 tests + ruff + mypy, C# 507 tests +
dotnet format --verify-no-changes + /warnaserror.

Release notes are added for all four packages (minor in each).

Adding .gitkeep for PR creation (default mode).
This file will be removed when the task is complete.

Issue: #45
@konard konard self-assigned this Aug 27, 2026
konard added 10 commits August 27, 2026 14:55
A single control character makes encode and encode_line replace the whole
string with base64, so a log message holding a newline stops being readable
and greppable, and a key holding one silently turns its object into an array.

See #45
encode() and encode_line() replaced a whole string with a base64 payload
as soon as it held one control character, so a multi-line log message
stopped being greppable. They now write the text itself: a value using
the quote delimiter is wrapped in a run of delimiters the notation reads
literally, a newline stays raw in the indented form, and only characters
the chosen form cannot carry are percent-escaped under a new
(escaped "...") marker. base64 stays where it was asked for, in
encode_compact()/encode_obfuscated(), and (base64 "...") is still read.

The shared fixtures gain the cases the issue asks for -- a multi-line
string, strings holding the quote delimiter, a repeated value, a real
traceback -- and a legacy section pinning that documents written up to
0.6.0 keep decoding.
Port the Rust change: the readable forms write a string as text, escape
only the characters the form itself cannot carry, and quote with a run of
delimiters instead of doubling them. `(escaped "...")` replaces the
`(base64 "...")` marker, which stays readable for older documents.
encode() and encode_line() replaced a whole string with a base64 payload
as soon as it held one control character, so a multi-line log message
stopped being greppable. They now write the text itself: a value using
the quote delimiter is wrapped in a run of delimiters the notation reads
literally, a newline stays raw in the indented form, and only characters
the chosen form cannot carry are percent-escaped under a new
(escaped "...") marker. base64 stays where it was asked for, in
encode_compact()/encode_obfuscated(), and (base64 "...") is still read.

A key holding a control character now stays a key rather than turning the
dict it belongs to into a list, and the conformance suite gains the legacy
section that pins documents written up to 0.6.0 as still decodable.
The issue reports that parse_lino desynchronises on the doubled-quote
form, so 241 of 6,000 fuzzed values came back as something else. The new
test encodes twelve values -- quotes of both kinds, leading and trailing
quotes, runs of quotes -- and asserts links-notation reads each one back
as exactly the text that was written.
A single control character used to turn a whole string into base64, so a
log message holding one newline hid its own text: the message, the stack
trace and every word a reader would grep for.

Both readable forms now write the text as it is and escape only what the
form itself cannot carry -- the newline on a single line, the carriage
return everywhere, and the remaining control characters -- with a new
(escaped "...") marker whose payload is percent-escaped, so the escaped
part stays readable too. A value holding the quote delimiter is written
with a run of delimiters rather than doubled quotes, which the notation's
own parser reads back unchanged.

(base64 "...") is still decoded, so documents written up to 0.6.0 keep
reading, and a key holding a control character now stays a key instead of
turning its object into an array.
Every README said a value holding a control character is base64-encoded.
It is not: both readable forms write the text as it is, use a run of
delimiters for a value holding the quote, percent-escape only what the
form cannot carry, and write a repeated value out every time. base64 is
now described where it still happens -- the compact form, which asks for
it by name.

Also re-exports ESCAPED_MARKER from the JavaScript entry point, which
Python, Rust and C# already expose, and adds the release notes for all
four packages.
@konard konard changed the title [WIP] encode_line base64-encodes any string with a newline, so 70% of a real log's text stops being greppable fix: write text as text in both readable forms instead of base64 Aug 27, 2026
@konard
konard marked this pull request as ready for review August 27, 2026 15:37
@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

Working session summary

All non-success checks are SKIPPED release jobs (they only run on main). Every CI job on the head commit passed.

PR: #46 — ready for review, mergeable, all 9 workflow runs green on 1154c50.

What was wrong

encode/encode_line treated every control character as unwritable, so a single newline base64-encoded the whole string — the message, the stack trace and every greppable word went with it. Separately, the doubled-quote escape used for values containing " desynchronises the notation's own parser; experiments/issue-45/quote-probe measures this directly:

1 alone  content="say \"hi\""  doc="(a \"say \"hi\"\")"  -> Some("say ")  MISMATCH

An unrelated bug surfaced while fixing it: a key holding a control character was written as (base64 "…") in key position and read back as an array element, so {"a\nb": "a\nb"} silently decoded to ["a\nb", "a\nb"].

What changed, in all four languages

Both readable forms write text as text. Only what the form itself cannot carry is percent-escaped, in a (escaped "…") value: the newline on a single line, the carriage return everywhere, and the remaining control characters. Tabs stay tabs. Values holding the quote delimiter use the n-quote form ("""say "hi""""). Repeated values are written out every time. base64 is reachable only through encode_compact/encode_obfuscated. (base64 "…") and the two older quoting conventions still decode, pinned by a legacy fixture section.

Verification

  • Reproducing suites (9–10 tests each) in Rust, JS, Python and C#, plus a Rust test that reads twelve encoded values back through links_notation::parse_lino_to_links.
  • 52 shared fixtures + 4 legacy, byte-identical across the four implementations.
  • Local: Rust 8 targets + clippy clean; JS 497 tests + npm run check; Python 488 tests + ruff + mypy; C# 507 tests + dotnet format --verify-no-changes + /warnaserror.
  • Release notes added for all four packages (minor).

One finding worth flagging: the four packages pin different links-notation releases whose quoting rules disagree (Rust 0.14 implements the n-quote form; C# 0.13 and the npm/PyPI 0.11.2 packages do not). The readable format is parsed by this repository's own tokenizer in every language, so values round-trip regardless — but only the Rust suite can prove the output against the notation library itself. The measured comparison is in experiments/issue-45/README.md; upgrading the JS/Python/C# notation dependencies would be a separate change.


This summary was automatically extracted from the AI working session output.

@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

🤖 Solution Draft Log

This log file contains the complete execution trace of the AI solution draft process.

💰 Cost: $22.087043

📊 Context and tokens usage:

Claude Opus 5: (6 sub-sessions)

  1. 116.3K / 1M (12%) input tokens, 32.0K / 128K (25%) output tokens
  2. 110.1K / 1M (11%) input tokens, 42.2K / 128K (33%) output tokens
  3. 117.4K / 1M (12%) input tokens, 25.8K / 128K (20%) output tokens
  4. 116.8K / 1M (12%) input tokens, 29.9K / 128K (23%) output tokens
  5. 115.9K / 1M (12%) input tokens, 25.9K / 128K (20%) output tokens
  6. 97.8K / 1M (10%) input tokens, 24.2K / 128K (19%) output tokens

Total: (17.0K new + 567.7K cache writes + 20.8M cache reads) input tokens, 236.2K output tokens, $22.087043 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Thinking level: high (~23999 tokens)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (7180KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard
konard merged commit a56f725 into main Aug 27, 2026
54 checks passed
@konard

konard commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

🎉 Auto-merged

This pull request has been automatically merged by hive-mind.

  • All CI checks have passed

Auto-merged by hive-mind with --auto-merge flag

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

encode_line base64-encodes any string with a newline, so 70% of a real log's text stops being greppable

1 participant