Skip to content

feat(stream_inspect): adopt the capability #352 asked about, not upstream's implementation - #376

Open
PYDuquesnoy wants to merge 5 commits into
masterfrom
feat/stream-inspect
Open

PYDuquesnoy wants to merge 5 commits into
masterfrom
feat/stream-inspect

Conversation

@PYDuquesnoy

Copy link
Copy Markdown
Contributor

Issue 352's four-tool profile decision: one adopt, two declines with measured reasons, one premise
correction.
Stacked on #375, which created the file the decisions are recorded in — base it on
master after that merges.

iris_coverage — already adopted; the premise is stale

352 says all four are "absent from our 23". iris_coverage is in INTEROP_TOOLS and has been since
it was added after the issue was filed. Nothing to decide.

stream_inspect — ADOPT, precondition verified first

352 makes adoption conditional on one thing: "confirmed non-consuming before adoption". Measured on
IRIS 2026.1 against a throwaway %Stream.GlobalCharacter (probe class deleted afterwards):

step result
instance A: Read(100), then again "line-1", then "" with AtEnd=1
instance B: a fresh %OpenId on the same id, after A hit AtEnd Size=6, Read → "line-1"
instance C: read .Size first, then Read(100) 6, then "line-1"
instance A again still "", still AtEnd=1

Read position is per-instance and never saved, and reading Size advances nothing. Precondition
met.

It also corrects the issue's framing. 352 wants an answer to "is the stream empty, or already
read?"
— but since position is never persisted, "already read" is not a state a later reader can
observe
. A stream a relay consumed to the end still reads from the start for the next %OpenId.
The real question is "what is in this stream", and the confusion worth preventing is a different one:
an id that opens nothing versus a stream that holds nothing.

Adopting the capability, not the port

Upstream's stream_inspect_impl was read at upstream/master before writing this. Three deliberate
differences, each one a rule this repo already has:

upstream here why
Do stream.Rewind() nothing is called that mutates its harmlessness is a property of IRIS, not of the tool; a reader should not write
While stream.AtEnd=0 { Set content=content_stream.Read(4096) }, no cap Read(max_chars), size reported separately measured on this instance family: 3,600,000 chars survive a round trip, 3,700,000 fails <MAXSTRING> → SQLCODE ‑400 → ‑149. It would fail on exactly the payload you most want to see
`Write "CONTENT "_content` — one line write_marker_lines with a declared line count

Three answers where upstream has two

{"success": true, "size": 0, "empty": true, "body": ""}          // opened, holds nothing
{"error_code": "STREAM_NOT_FOUND", "error": "… This is NOT an empty stream. …"}  // id opens nothing
{"error_code": "STREAM_UNREADABLE", "error": "… not an empty stream, an unusable reply …"}

Plus truncated + max_chars when the body is a prefix, body_incomplete + both line counts when
less arrived than IRIS declared, and for a binary stream the size with the bytes omitted — bytes
cannot cross a text protocol intact, and a mangled body is worse than none.

iris_generate_test — DECLINE, on the precondition it cannot meet

352 requires "output confirmed to match tdd's prescribed shape". Read at upstream/master:

pub const GENERATE_TEST_SYSTEM: &str = r#"… Generate a complete %UnitTest.TestCase subclass …
- Extend %UnitTest.TestCase
- Test methods MUST start with "Test" prefix
- Use $$$AssertEquals, $$$AssertTrue, $$$AssertNotNull macros …"#;

It asks for %UnitTest.TestCase, where tdd requires %UnitTest.TestProduction with
Parameter PRODUCTION and TestControl neutralised. Nothing about asserting on a field the
component writes. And the only validation of what comes back is:

pub fn validate_cls_syntax(text: &str) -> bool {
    text.contains("Class ") && text.contains('{')
        && text.matches('{').count() == text.matches('}').count()
}

So the shape is a request to an LLM, not a guarantee — the acceptance criterion has no answer for a
non-deterministic generator, and adopting it would have this fork advertise a tool its own gates
fight.

mermaid_production — DECLINE, on measurement

Its implementation reads only Ens_Config.Item and emits every item as a child of the production
node — a one-level fan-out, no edge between items. Measured against a real 10-item production:

n
item→item edges knowable from TargetConfigName(s) 2
items whose routing lives in a business rule instead 2
edges the imported diagram would draw between items 0

This fork already reads both sources — items via iris_production_item(list), targets via
get_settings, which #372 made readable for many items in one round trip — and
iris_business_rule_info already parses the rule. So it can answer a question the port cannot;
importing it would ship the weaker one.

A defect this found in #375's own gate

The upstream-surface scan read ours from HEAD and INTEROP_TOOLS from the worktree. While
this change was uncommitted the two halves disagreed: stream_inspect appeared as advertised and
never-ported, and the #353 join then reported it UNCLASSIFIED. Measured: exit 1 before the
commit, exit 0 after, with no source change in between.
Both halves now read HEAD and the section
prints that caveat; both_halves_of_the_surface_comparison_read_the_same_ref guards it.

Re-recorded counts, measured row by row

Three pinned gates caught the new tool, which is what they are for:
the_read_only_split_is_pinned_per_toolset (all four toolsets +1) and two wire-level pins in
mcp_handshake. The read-only figures are unchanged, and that is the measured point rather than an
oversight:
stream_inspect is in GENERATOR_WRITE_TOOLS because it reads a stream through a
scratch class, so it is honestly not advertised readOnlyHint:true even though it writes nothing to
the stream. Each row was recorded by running the gate and reading what it said, not by arithmetic.

Manifest row is unit-ok, not OK: the unit tests are named and green, and there is no e2e row yet.

Verification

  • cargo fmt --all -- --check clean, cargo clippy --workspace --all-targets -- -D warnings clean,
    cargo build --workspace, bash scripts/validate-tools.sh exit 0.
  • Derived test gate with IRIS env unset: 11 result lines, 0 FAILED. All 12 new tests confirmed by
    name; a fabricated name returns 0.
  • 10 mutations, each red on its named test and restored: the program rewinds the stream; the read
    is not the capped Read; the body goes out on one line; size read after the body; an id that opens
    nothing reports an empty stream; a no-marker reply reads as a found empty stream; a prefix not
    flagged truncated; a binary stream's bytes reported as text; a short body not flagged incomplete;
    the profile read from the worktree again.

Three of those needed re-targeting before they produced a verdict — two anchors broke the format!
string and one left an unused named argument. All three were compile errors, not survivors, and
none was counted until it compiled and went red.

Not verified against a live instance

The generated program is unit-tested, as every build_*_code in this repo is, but it has not been
run: stream_inspect reaches IRIS through execute_via_generator, which PUTs a scratch class, and
the only instance I can write to has no reason to hold an interop stream. The measurement at the top
of this PR was taken by driving the same ObjectScript by hand over Atelier, which is why the
non-consuming claim is evidence rather than inference — but the tool's own path first executes in
CI's e2e job.

PYDuquesnoy and others added 3 commits September 24, 2026 00:54
Issue 353's remaining acceptance item. #359 made the mechanical half computed — which of
upstream's tools we advertise, which we ported and pruned, which we never ported, read from the
two trees rather than restated. That says nothing about WHY, and why is the whole difference
between a profile and an omission.

## The issue's own numbers are stale, and this writes none down

353 says upstream documents 71 tools, the profile is 23, and 48 are excluded. Computed from
upstream/master and HEAD today: 93 upstream, 32 advertised (30 upstream + 2 fork-local), 63
upstream-only. So the file carries NO counts — a number here would be a second source of truth
for something `scripts/validate-tools.sh` prints, and every prose count in this repo has gone
stale in both directions.

## Four categories, because two cannot distinguish "never" from "not yet"

`tools-excluded.json` classifies all 63:

* `unsafe-here` (6) — lets an agent damage an instance or reach past what this fork is for.
  global_kill, global_preview, iris_global, and the iris_ws_* trio.
* `not-interop` (41) — legitimate upstream capability outside the remit: instance
  administration, the server registry, containers, the skill/KB machinery, agent telemetry.
* `covered-here` (5) — already answered by a tool the fork advertises: iris_source_control by
  iris_doc's own checkout, the four debug_* by iris_debug and iris_get_log.
* `wanted` (11) — would help interop work and is not excluded on principle. compare_namespace,
  compare_document, stream_inspect, iris_generate_test, mermaid_production, resolve_storage carry
  the issue tracking the decision; iris_search, iris_info, iris_namespace_list, my_access and
  resolve_dynamic_dispatch say what they would be for.

Every reason is grounded in upstream's own tool description, read from upstream/master rather
than inferred from the tool's name.

## Two gates, split by what each can actually check

The JOIN — did every upstream-only tool get classified — needs `upstream/master`, and CI clones do
not carry that remote; the surface section already prints NOT CHECKED and exits 0 there for that
reason. So it lives in the script as a maintainer gate that exits 1, and reports three distinct
faults: UNCLASSIFIED (an upstream tool with no entry), STALE (a reason for a tool upstream no
longer has), CONTRADICTORY (advertised and excluded at once).

Everything that needs no remote is a Rust test, so it runs in the required CI gate: the file
parses, every category is one the file itself defines, every reason is a real sentence rather than
"not ported" restating the computed bucket, a tracking reference only ever sits on a `wanted`
entry, nothing is both advertised and excluded, and no count is written down.

## Verification

fmt, clippy -D warnings, cargo build --workspace, `scripts/validate-tools.sh` exit 0, and the
derived test gate with IRIS env unset: 8 result lines, 0 FAILED. Targets chosen by grepping tests/
for what this diff touches rather than from memory.

9 mutations, each red and restored: a reason that restates the bucket; an undefined category; a
reason too short to be one; a tracking ref on a tool excluded on principle; an advertised tool
given an exclusion reason (caught by both the test and the script); a count written into the file;
an upstream-only tool losing its entry; a reason for a tool upstream does not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ream's implementation

Issue 352's four-tool profile decision. One adopt, two declines with measured reasons, and one
premise correction.

## iris_coverage — already adopted; the issue's premise is stale

352 says all four are "absent from our 23". iris_coverage is in INTEROP_TOOLS and has been since
it was added after the issue was filed. Nothing to decide.

## stream_inspect — ADOPT, and its precondition was verified first

352 makes adoption conditional on "confirmed non-consuming before adoption". Measured on IRIS
2026.1 with a throwaway %Stream.GlobalCharacter (probe deleted afterwards):

  instance A: Read, Read again          -> "line-1", then "" with AtEnd=1
  instance B: a FRESH %OpenId, after A  -> Size=6, Read -> "line-1"
  instance C: read .Size FIRST, then Read -> 6, then "line-1"
  instance A again                      -> still "", still AtEnd=1

Read position is per-instance and never saved. So an inspector that opens the stream itself
cannot consume what a later reader needs, and reading Size advances nothing.

That also corrects the issue's framing. It asks for "is the stream empty, or already read?" —
but since position is never persisted, "already read" is not a state a later reader can observe.
A stream a relay consumed still reads from the start for the next %OpenId.

### The capability, not the port

Upstream's stream_inspect_impl was read at upstream/master. Three deliberate differences:

1. It calls `Do stream.Rewind()` — a mutation on an object the caller asked us to LOOK at, whose
   harmlessness is a property of IRIS rather than of the tool. Nothing here writes.
2. It reads the ENTIRE stream into one variable with no cap. Measured on this instance family,
   3,600,000 characters survive a round trip and 3,700,000 fails <MAXSTRING> -> SQLCODE -400 ->
   -149, so it would fail on exactly the payload a caller most wants to see. This reads max_chars
   and reports the full size separately, with `truncated` when what came back is a prefix.
3. It writes the body on ONE line. An HL7 v2 message is CR-separated and any XML or JSON body
   contains newlines, so every line after the first is dropped silently — the #347 defect this
   repo has already fixed twice. The body goes through write_marker_lines with a declared line
   count, so a short arrival is detectable instead of plausible.

And the answer #352 actually wanted is kept distinct: an id that opens nothing reports
STREAM_NOT_FOUND and says out loud that it is NOT an empty stream; a stream that opened and holds
nothing reports size 0 with empty: true; a reply with no marker at all is STREAM_UNREADABLE. Three
different places to look, three different answers.

## iris_generate_test — DECLINE, on the precondition it cannot meet

352 requires "output confirmed to match tdd's prescribed shape". Its system prompt asks the model
to "Extend %UnitTest.TestCase", while tdd requires %UnitTest.TestProduction with Parameter
PRODUCTION and TestControl neutralised. Its only output check is validate_cls_syntax — contains
"Class ", contains a brace, braces balanced. So the shape is a request to an LLM, not a guarantee,
and the acceptance criterion has no answer for a non-deterministic generator.

## mermaid_production — DECLINE, on measurement

It reads only Ens_Config.Item and draws every item as a child of the production node: a one-level
fan-out with no edge between items. On a real 10-item production, 2 item-to-item edges are
knowable from TargetConfigName(s) and 2 more items route through a business rule — the imported
diagram would show 0 of those 4. This fork already reads both sources, and #372 made the settings
read batched, so it can answer a question the port cannot.

## A defect this found in #375's own gate

The upstream-surface scan read `ours` from HEAD and INTEROP_TOOLS from the WORKTREE. While this
change was uncommitted the two disagreed — stream_inspect showed as advertised AND never-ported,
and the #353 join then reported it UNCLASSIFIED. Measured: exit 1 before the commit, exit 0 after,
with no source change in between. Both halves now read HEAD and the section prints the caveat.

## Re-recorded counts, measured row by row

Three gates pin the advertised count and caught this, as designed:
the_read_only_split_is_pinned_per_toolset (all four toolsets +1) and two wire-level pins in
mcp_handshake. The read-only figures are UNCHANGED and that is the measured point: stream_inspect
is in GENERATOR_WRITE_TOOLS because it reads through a scratch class, so it is honestly not
advertised readOnlyHint:true even though it writes nothing to the stream.

## Verification

fmt, clippy -D warnings, cargo build --workspace, scripts/validate-tools.sh exit 0, and the
derived test gate with IRIS env unset: 11 result lines, 0 FAILED. 12 new tests confirmed by name.

10 mutations, each red on its named test and restored: the program rewinds the stream; the read is
not the capped Read; the body goes out on one line; size read after the body; an id that opens
nothing reports an empty stream; a no-marker reply reads as a found empty stream; a prefix not
flagged truncated; a binary stream's bytes reported as text; a short body not flagged incomplete;
the profile read from the worktree again. Three needed re-targeting first — two anchors broke the
format string and one left an unused named argument, all three compile errors rather than
survivors, and none was counted until it compiled and went red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI caught six pinned counts the previous commit missed, and they were right to. Adding a tool
moves the advertised surface, and this repo pins it in more places than one grep found:

  the_read_only_split_is_pinned_per_toolset   mod.rs        (already done)
  mcp_handshake                               x2            (already done)
  interop_e2e_tests::tools_list_returns_interop_profile      32 -> 33
  test_toolset::test_baseline_tool_count                     61 -> 62
  test_toolset::test_nostub_tool_count                       57 -> 58
  test_toolset::test_merged_tool_count                       53 -> 54
  test_toolset::test_toolset_counts_match_doc_comments       all four rows
  test_toolset::test_new_uses_pruned_baseline_router         61 -> 62
  Toolset enum doc comments                                  three headline counts

Why my derived target list missed them: it globbed crates/iris-agentic-dev-core/tests/*.rs and
test_toolset.rs lives in tests/unit/. A nested test module is reached through a top-level
harness file, so the glob has to be recursive or the target is invisible to the derivation that
is supposed to find it.

The read-only figures are unchanged in every row, which is the measured point rather than an
omission: stream_inspect is in GENERATOR_WRITE_TOOLS because it reads through a scratch class, so
it is honestly not advertised readOnlyHint:true even though it writes nothing to the stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PYDuquesnoy

Copy link
Copy Markdown
Contributor Author

CI green now — all 5 jobs, 11 new tests plus the re-recorded profile assertions confirmed by name in the log, fabricated name 0.

The first run failed on six pinned tool counts, and they were right to fail: adding a tool moves the advertised surface and this repo pins it in nine places. Three I had already re-recorded; the other six were interop_e2e_tests, four assertions in test_toolset (including its doc-comment table and the INTEROP_TOOLS.len() anchor), and three Toolset enum doc-comment headlines.

Why my derived target list missed them — worth recording because it is a second miss of the same kind: the derivation globs crates/iris-agentic-dev-core/tests/*.rs, and test_toolset.rs lives in tests/unit/. A nested module is reached through a top-level harness file, so the target name is not its own filename. And separately: grepping for the identifiers a diff touches cannot find a pinned NUMBER — ("baseline", Toolset::Baseline, 61, 38) contains nothing my change renamed. For a change that alters a surface size, the current numbers have to be part of the grep.

Every figure was re-recorded by running the gate and reading what it reported, not by arithmetic — which matters here, because the read-only counts do not move with the totals: stream_inspect is in GENERATOR_WRITE_TOOLS (it reads a stream through a scratch class), so it is honestly not advertised readOnlyHint:true even though nothing in it writes to the stream.

@PYDuquesnoy

Copy link
Copy Markdown
Contributor Author

Do not merge this as it stands — I ran the tool end-to-end for the first time and three of its headline claims are false. I had said live verification was blocked because my writable instance has no interop namespace; that was wrong, a %Stream.GlobalCharacter needs no interop namespace at all. Driving the built binary against a real instance (streams created and deleted on my own throwaway box):

case PR claims actually returned
stream_id: 999, no such stream STREAM_NOT_FOUND, "this is NOT an empty stream" success: true, size: 0, empty: true
a %Stream.GlobalBinary size reported, bytes omitted class: "%Stream.GlobalCharacter", body_chars: 1 — bytes returned as text, truncated at the first NUL
a CR-separated HL7 body "arrives whole however many newlines it contains" segments concatenated: `…

Why, measured

%OpenId("999")  -> ISOBJ=1  ISERR=0  text=""      <- a usable EMPTY object, not a failure
%Stream.GlobalCharacter.%OpenId("6") on a BINARY stream -> succeeds, $classname = %Stream.GlobalCharacter
%ExistsId("999")=0   %ExistsId("5")=1   %ExistsId("")=0
%Stream.GlobalBinary read of a CHARACTER stream -> 106 chars, both CRs intact
$System.Encryption.Base64Encode round-trip -> lossless

Three wrong premises of mine, each of which the unit tests could not see because they fed the parser hand-written marker text:

  1. '$IsObject cannot detect a missing stream. %OpenId on an unknown id hands back a usable empty object with an OK status, so the found/not-found branch never takes the not-found arm. %ExistsId is the primitive that actually distinguishes them.
  2. Character vs binary is not knowable from which %OpenId succeeded — both open the same storage, and $classname reports the class I opened with. So class was reporting my own choice back to me, and the body_omitted branch is unreachable.
  3. write_marker_lines deletes CR by design (objectscript.rs says so, and that is right for a CRLF %Status chain). An HL7 v2 body is CR-only, so it collapses to one line and the segment boundaries are destroyed silently — the declared-count check still says "complete", because one line is what was declared.

The third is the one I most regret: the PR text singles out HL7 as the case the line protocol exists for, and that is exactly the case it breaks.

The fix I am implementing

Read through %Stream.GlobalBinary always (measured: exact bytes for both kinds), %ExistsId for existence, and base64 for the body so CR, NUL and arbitrary bytes all survive — which also removes the unreachable binary branch. class becomes opened_as, since the data's kind is not knowable.

Also worth recording: a %Stream.GlobalCharacter saved with no content gets no id at all (%Id() came back empty), so a saved-and-empty character stream may not be a reachable state.

…ing it

I had said live verification was blocked because my writable instance has no interop namespace.
That was wrong — a %Stream.GlobalCharacter needs no interop namespace. Driving the built binary
against a real instance for the first time, three of this PR's headline claims were false.

  case                       PR claimed                      actually returned
  stream_id 999, no such id  STREAM_NOT_FOUND, 'NOT empty'   success:true size:0 empty:true
  a %Stream.GlobalBinary     size given, bytes omitted       class:GlobalCharacter, body_chars:1
  a CR-separated HL7 body    'arrives whole'                 segments concatenated, both CRs gone

## The three wrong premises, measured

  %OpenId("999")  -> $IsObject 1, ISERR 0, text ""   <- a usable EMPTY object, not a failure
  GlobalCharacter.%OpenId on a BINARY stream          -> succeeds; $classname = GlobalCharacter
  %ExistsId: "999"->0  "5"->1  ""->0
  GlobalBinary read of a CHARACTER stream             -> 106 chars, both CRs intact
  Base64Encode(x,1)                                   -> lossless round trip

1. `'$IsObject` cannot detect a missing stream, so the not-found arm never fired. `%ExistsId` can.
2. Character vs binary is NOT knowable from which %OpenId succeeded — both open the same storage
   and $classname reports the class you opened with. So `class` echoed my own choice back, and the
   binary branch was unreachable while binary bytes came back mangled.
3. `write_marker_lines` deletes CR by design — right for a CRLF %Status chain, destructive for a
   CR-only HL7 v2 body, which collapsed to one line with its segment boundaries gone while the
   declared count still said "complete".

The third is the one I most regret: this PR's text singles out HL7 as the case the line protocol
exists for, and that is the case it broke.

## The fix

%ExistsId for existence; always read through %Stream.GlobalBinary (exact bytes for either kind);
base64 for the body, which needs no line protocol and survives CR, NUL and anything else.
`body_base64` is always exact; `body` appears only when the bytes are valid UTF-8, otherwise
`body_not_text` points at the base64 rather than handing over a mangled string. `class` becomes
`opened_as`, because the data's kind is not knowable from here.

Re-verified end to end against a live instance after the fix: the HL7 body returns 106 bytes with
both CRs, the binary stream 15 exact bytes with no `body` offered, max_chars=20 of 500 flagged
truncated, and id 999 reports STREAM_NOT_FOUND. Probe streams and classes deleted afterwards.

## Two mutations survived first, and both named a missing oracle

* Dropping base64 padding survived, because the round-trip test checked my encoder against my own
  decoder — which filters padding out. `body_base64` goes to a caller using a real base64 library,
  so the padding is contract: now pinned against RFC 4648's vectors.
* Re-adding the CR-deleting $TRANSLATE survived every parser test, because those feed hand-written
  marker text and cannot see the ObjectScript. That is the same blindness that let the original
  defect ship. The program is now asserted to contain no $TRANSLATE, no $CHAR(13) and no $PIECE.

8 mutations, all with verdicts after re-targeting two invalid ones (a removed format argument, and
one that failed to compile): character-class read; $IsObject existence; padding dropped; lossy UTF-8
offered as text; a missing id reporting emptiness; an undecodable body becoming an empty one; a short
arrival unflagged; the CR-deleting translate restored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PYDuquesnoy added a commit that referenced this pull request Sep 24, 2026
Issue 351. Two premise corrections, a defect in #376 fixed, and a survivor that named a missing
assertion on the issue's own acceptance item.

compare_namespace_impl takes server_a, server_b and ONE namespace: it compares the same namespace
on two REGISTERED SERVERS. This fork has no server registry — it connects to one instance named by
IRIS_HOST/IRIS_NAMESPACE, which is why iris_servers and its siblings are recorded as out of remit.
There is nothing for server_a to resolve against.

So the axis is turned: two NAMESPACES on the connected instance. That covers the deployment case
server reaches IRIS over HTTP and has no access to the caller's project directory. That is the same
constraint recorded in #374, and it is why the source-of-truth check is a hook rather than a tool.

Its per-document arm is `_ => { different.push(doc.clone()) }` — a document neither side could read
is reported as a document that DIFFERS. A caller reading "12 classes differ" has no way to learn
that 12 fetches failed. Here an unreadable document is its own bucket, and an unreadable namespace
names which side, never a match report.

claimed only when nothing differs, nothing is one-sided AND nothing went unchecked.

Running it against real namespaces corrected my own first design: DEMO holds 4 `Comun.*` classes and
USER holds 0, so the intersection is empty — and collapsing that into "nothing compared" would have
HIDDEN the four classes missing from the other side, which is the answer a deployment check exists
to get. Only BOTH sides empty is that state now.

%Dictionary.CompiledClass.Hash, one SQL read per namespace — 2 round trips rather than upstream's
two document fetches per class (400 for a 200-class comparison). `compared_on: "Hash"` is on the
payload, because that is a weaker claim than identical source and this code does not make the
stronger one; compare_document reads both sources for a named class.

The %-exclusion was MEASURED, because the obvious spelling is wrong and a test over the generated
text cannot see it: on DEMO, no filter gives 15875 rows, `NOT LIKE '%%%'` gives 0 (three wildcards
— it excludes everything), and `NOT LIKE '\%%' ESCAPE '\'` gives 10116. The first version emitted
the middle one and its test asserted the clause was PRESENT, so it passed while the query it built
would have listed nothing on both sides.

The match I added `stream_inspect` to last night is `mutating_call`, not a label table — so #376
shipped a read-only inspector WRITE-GATED: refused on a production instance and whenever the write
gate is closed, which is exactly where diagnosing a relay matters. I repeated the mistake for both
compare tools, and the read-only count moving by 2 instead of 3 is what forced me to read
annotate_tool. All three are out of mutating_call now, with the reason recorded where the next
reader will look.

Correcting myself: #376's claim that stream_inspect is not advertised readOnlyHint:true BECAUSE it
is in GENERATOR_WRITE_TOOLS was right — `read_only = !tool_can_mutate && !tool_writes_via_generator`
— but the write-gating was a second, unnoticed error in the same edit.

Capping `unchecked_count` the way the NAME list is capped went undetected: the cap test leaves 3
unchecked and the name cap is 50, so `take(50).count()` and `len()` agree. #351's acceptance is
exactly "unchecked_count surfaced, never silently truncated", so the oracle could not see the thing
it was written for. `the_unchecked_count_is_exact_even_past_the_name_cap` drives 67 past the cap;
the mutation is red against it.

Separately, `a_tracking_reference_points_somewhere_real` asserted `tracked >= 3` and went red
because this change ADOPTED three of the tracked tools. A test that fails when the thing it tracks
gets done punishes the outcome it encourages, so the quota is now a positive control (>= 1) with the
reason recorded.

fmt, clippy -D warnings, cargo build --workspace, scripts/validate-tools.sh exit 0, and the derived
test gate with IRIS env unset: 11 result lines, 16 new tests confirmed by name, control 0. Target
list derived with a RECURSIVE glob this time, which is what missed tests/unit/ last night.

11 mutations, each red on its named test and restored: a failed side degrading to an empty listing;
a reply with no content array becoming one; an unreadable side reported as a comparison; both sides
empty reading as clean; an empty intersection collapsing and hiding one-sided classes; in_sync
ignoring unchecked and one-sided classes; the unchecked count capped; an unreadable document
reported as a difference; the %-exclusion losing its ESCAPE; the compare tools write-gated again;
the last tracking reference removed.

The nine pinned tool counts are re-recorded by running each gate and reading what it reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PYDuquesnoy

Copy link
Copy Markdown
Contributor Author

Fix pushed and CI green on all 5 jobs (tests confirmed by name, fabricated name 0). Re-verified end to end against a live instance after the change: the HL7 body comes back 106 bytes with both CRs intact, the binary stream 15 exact bytes with no body offered, max_chars=20 of 500 flagged truncated, and id 999 reports STREAM_NOT_FOUND. Probe streams and classes deleted afterwards.

Two mutations survived the first pass and both named a missing oracle:

  • Dropping the base64 padding survived, because the round-trip test checked my encoder against my own decoder — which filters padding out. body_base64 goes to a caller using a real base64 library, so the padding is part of the contract; it is now pinned against RFC 4648's own vectors instead.
  • Re-adding the CR-deleting $TRANSLATE survived every parser test, because those feed hand-written marker text and cannot see the ObjectScript. That is the same blindness that let the original defect ship, so the program itself is now asserted to contain no $TRANSLATE, no $CHAR(13) and no $PIECE.

#377 needed a rebase onto this branch's new tip and now merges cleanly; its CI is green too. One process note: my first run of the handshake tests after that rebase reported the profile as 33 tools instead of 35, and I nearly wrote it up as the rebase having dropped the INTEROP_TOOLS entries. The source had all 35 — mcp_handshake spawns the binary, and I had not rebuilt after rebasing. The cron's own rule about building before an e2e run exists for exactly that.

…entation

Found by RUNNING compare_namespace, not by reading it: the INVALID_PARAMS text came back as
"Comparing a namespace with                      itself always reports in_sync".

A Rust string continued with a trailing backslash collapses to single spaces, so the usual
multi-line literal is fine. These five had the continuation COLLAPSED into one source line at some
point, which leaves the indentation inside the runtime string. Verified with a real rustc compile
and run that the re-split form produces single spaces, and by driving the tool: the repaired
stream_inspect message now comes back with no double space in it.

One of the five is mine from this week (stream_inspect's MISSING_PARAMS); the other four are
pre-existing caller-facing text — three iris_coverage hints and iris_doc's put/names refusal. Fixed
together because a guard over this class is only sound on a clean tree, and an exemption list for
four known offenders would rot.

## The detector, and why the obvious one does not work

A first scan reported 592 hits across 68 files and every one I checked was a false positive: it
matched across lines, so it was reading the SOURCE layout of correct -continuations rather than
the runtime string. The signature that actually distinguishes them is a 3+ space run with non-space
on both sides WITHIN ONE source line — a correct continuation puts the indentation at the start of
the NEXT line, where a single-line view never sees it. That narrowed 592 to 92, of which these five
were the caller-facing ones; the rest are whitespace test fixtures ("   "), embedded ObjectScript
whose indentation is deliberate, and aligned trailing comments.

No test ships with this. The detector needs exclusion rules for those three noise classes, and a
guard that reddens on correct code is one I have had to loosen twice this week — so it gets filed
with its measurements rather than bolted on at the end of a night.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PYDuquesnoy added a commit that referenced this pull request Sep 24, 2026
Issue 351. Two premise corrections, a defect in #376 fixed, and a survivor that named a missing
assertion on the issue's own acceptance item.

compare_namespace_impl takes server_a, server_b and ONE namespace: it compares the same namespace
on two REGISTERED SERVERS. This fork has no server registry — it connects to one instance named by
IRIS_HOST/IRIS_NAMESPACE, which is why iris_servers and its siblings are recorded as out of remit.
There is nothing for server_a to resolve against.

So the axis is turned: two NAMESPACES on the connected instance. That covers the deployment case
server reaches IRIS over HTTP and has no access to the caller's project directory. That is the same
constraint recorded in #374, and it is why the source-of-truth check is a hook rather than a tool.

Its per-document arm is `_ => { different.push(doc.clone()) }` — a document neither side could read
is reported as a document that DIFFERS. A caller reading "12 classes differ" has no way to learn
that 12 fetches failed. Here an unreadable document is its own bucket, and an unreadable namespace
names which side, never a match report.

claimed only when nothing differs, nothing is one-sided AND nothing went unchecked.

Running it against real namespaces corrected my own first design: DEMO holds 4 `Comun.*` classes and
USER holds 0, so the intersection is empty — and collapsing that into "nothing compared" would have
HIDDEN the four classes missing from the other side, which is the answer a deployment check exists
to get. Only BOTH sides empty is that state now.

%Dictionary.CompiledClass.Hash, one SQL read per namespace — 2 round trips rather than upstream's
two document fetches per class (400 for a 200-class comparison). `compared_on: "Hash"` is on the
payload, because that is a weaker claim than identical source and this code does not make the
stronger one; compare_document reads both sources for a named class.

The %-exclusion was MEASURED, because the obvious spelling is wrong and a test over the generated
text cannot see it: on DEMO, no filter gives 15875 rows, `NOT LIKE '%%%'` gives 0 (three wildcards
— it excludes everything), and `NOT LIKE '\%%' ESCAPE '\'` gives 10116. The first version emitted
the middle one and its test asserted the clause was PRESENT, so it passed while the query it built
would have listed nothing on both sides.

The match I added `stream_inspect` to last night is `mutating_call`, not a label table — so #376
shipped a read-only inspector WRITE-GATED: refused on a production instance and whenever the write
gate is closed, which is exactly where diagnosing a relay matters. I repeated the mistake for both
compare tools, and the read-only count moving by 2 instead of 3 is what forced me to read
annotate_tool. All three are out of mutating_call now, with the reason recorded where the next
reader will look.

Correcting myself: #376's claim that stream_inspect is not advertised readOnlyHint:true BECAUSE it
is in GENERATOR_WRITE_TOOLS was right — `read_only = !tool_can_mutate && !tool_writes_via_generator`
— but the write-gating was a second, unnoticed error in the same edit.

Capping `unchecked_count` the way the NAME list is capped went undetected: the cap test leaves 3
unchecked and the name cap is 50, so `take(50).count()` and `len()` agree. #351's acceptance is
exactly "unchecked_count surfaced, never silently truncated", so the oracle could not see the thing
it was written for. `the_unchecked_count_is_exact_even_past_the_name_cap` drives 67 past the cap;
the mutation is red against it.

Separately, `a_tracking_reference_points_somewhere_real` asserted `tracked >= 3` and went red
because this change ADOPTED three of the tracked tools. A test that fails when the thing it tracks
gets done punishes the outcome it encourages, so the quota is now a positive control (>= 1) with the
reason recorded.

fmt, clippy -D warnings, cargo build --workspace, scripts/validate-tools.sh exit 0, and the derived
test gate with IRIS env unset: 11 result lines, 16 new tests confirmed by name, control 0. Target
list derived with a RECURSIVE glob this time, which is what missed tests/unit/ last night.

11 mutations, each red on its named test and restored: a failed side degrading to an empty listing;
a reply with no content array becoming one; an unreadable side reported as a comparison; both sides
empty reading as clean; an empty intersection collapsing and hiding one-sided classes; in_sync
ignoring unchecked and one-sided classes; the unchecked count capped; an unreadable document
reported as a difference; the %-exclusion losing its ESCAPE; the compare tools write-gated again;
the last tracking reference removed.

The nine pinned tool counts are re-recorded by running each gate and reading what it reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PYDuquesnoy added a commit that referenced this pull request Sep 24, 2026
Ran this PR's test-after-put against a real instance for the first time. The behaviour is right in all
five cases — a green suite gives test_ok: true; a RED suite gives test_ok: false while the write's own
success stays true, which is the #310 separation working live; no compile gives test_skipped; a compile
failure gives COMPILE_ERROR with the write envelope untouched and no test block; and no `test`
parameter adds nothing at all.

What was wrong is the text. The skip message came back as:

  not run: `test` needs a COMPILED class and this call did not compile. Pass                  compile=true

Four messages, all in this PR: the three TestGate::skipped_reason arms and the unparseable-result
note. Same collapsed line-continuation as the seven fixed in #376/#377 — I fixed those on the branches
I happened to be working in and did not check the others.

## The scan that found the rest, and the one that did not

Running the earlier detector over every branch was useless: master-descended branches report 15-40 hits
each, because most matches are whitespace test fixtures, embedded ObjectScript whose indentation is
deliberate, and aligned doc-comment tables. No conclusion is available from those numbers.

Scoping it to the lines a branch ADDS (`git diff origin/master...origin/<br>`, `^+` only) makes the
population precise. Across all fifteen open PR branches that gives four real leaks, all four here.
Everything else the scan flags on those branches is intentional: measurement tables in doc comments,
the deliberate `"ERROR #5002:   "` truncation fixture, the real IRIS SQLCODE text in #368's fixtures
(which genuinely contains double spaces), and the leaked string the #380 detector test asserts on.

Verified at runtime after the fix by driving the built binary: the skip message contains no double
space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PYDuquesnoy

Copy link
Copy Markdown
Contributor Author

Recording the #352 linkage, because nothing in either PR references it and merging would leave it open — but deliberately not adding Closes #352, since that issue needs this PR and #375 together.

#352 asks for "a decision recorded per tool: adopt, or decline with a reason" across four tools. Where each one's status actually lives:

tool status where
stream_inspect adopted (capability re-implemented, not ported) this PR
iris_coverage already in the profile — advertised today, verified via tools/list already shipped
iris_generate_test wanted, with a reason #375's tools-excluded.json
mermaid_production wanted, with a reason #375's tools-excluded.json

So #352 is satisfied by #375 + #376 jointly. Closing it is a merge-time action once both are in, which is why neither PR carries the keyword.

Two concerns I raised and then measured away

Both worth recording so nobody re-derives them:

  1. "feat(tools): record WHY each upstream tool is not in the interop profile #375 lists stream_inspect as wanted while this PR adopts it — the manifest will contradict the profile, and validate-tools.sh fails on contradictory entries." Not true. Simulated the merge (git merge-tree --write-tree of both heads, exit 0, clean): in the merged tree stream_inspect is absent from tools-excluded.json and present 9× in mod.rs. This PR removes the manifest entry as part of adopting the tool, so the two stay consistent.

  2. "iris_coverage has no decision recorded anywhere." Also not true — it is already advertised in the profile, which is the strongest form of "adopt". It is absent from the excluded manifest for that reason, not by omission.

Also worth noting for whoever merges: #352's own counts are stale — it was filed when the profile was 23 tools; it is 35 now.

@PYDuquesnoy
PYDuquesnoy changed the base branch from feat/record-why-each-upstream-tool-is-excluded to master September 28, 2026 09:14
PYDuquesnoy added a commit that referenced this pull request Sep 28, 2026
Ran this PR's test-after-put against a real instance for the first time. The behaviour is right in all
five cases — a green suite gives test_ok: true; a RED suite gives test_ok: false while the write's own
success stays true, which is the #310 separation working live; no compile gives test_skipped; a compile
failure gives COMPILE_ERROR with the write envelope untouched and no test block; and no `test`
parameter adds nothing at all.

What was wrong is the text. The skip message came back as:

  not run: `test` needs a COMPILED class and this call did not compile. Pass                  compile=true

Four messages, all in this PR: the three TestGate::skipped_reason arms and the unparseable-result
note. Same collapsed line-continuation as the seven fixed in #376/#377 — I fixed those on the branches
I happened to be working in and did not check the others.

## The scan that found the rest, and the one that did not

Running the earlier detector over every branch was useless: master-descended branches report 15-40 hits
each, because most matches are whitespace test fixtures, embedded ObjectScript whose indentation is
deliberate, and aligned doc-comment tables. No conclusion is available from those numbers.

Scoping it to the lines a branch ADDS (`git diff origin/master...origin/<br>`, `^+` only) makes the
population precise. Across all fifteen open PR branches that gives four real leaks, all four here.
Everything else the scan flags on those branches is intentional: measurement tables in doc comments,
the deliberate `"ERROR #5002:   "` truncation fixture, the real IRIS SQLCODE text in #368's fixtures
(which genuinely contains double spaces), and the leaked string the #380 detector test asserts on.

Verified at runtime after the fix by driving the built binary: the skip message contains no double
space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PYDuquesnoy added a commit that referenced this pull request Sep 28, 2026
…ether anything compiled (#371)

* feat(iris_doc): run the named test after a compiled write, and say whether anything compiled

Items 2 and 4 of issue 327, measured over 31,009 parsed `tool_use` blocks from 364 transcripts.

`iris_doc(put)` is immediately followed by `iris_test` 579 times. A boolean would cover only
the 346 where the class written IS the class tested; a STRING covers all 579, because in the
other 233 the suite lives beside the class (`Hospital.BO.PatientDb` ->
`Hospital.Tests.BO.PatientDbTest`).

The decision is a pure `TestGate` with four states, not a boolean, because the ways a test does
NOT run need different fixes from the caller:

* `Run(pattern)` — the payload reports a compiled class.
* `NotRequested` — nothing added to the payload at all. A `test_skipped` on every ordinary put
  would be noise on the 2,979 measured puts that want none of this.
* `SkippedCallFailed` — the write did not succeed. Its own envelope is returned UNTOUCHED: it
  already carries the compile error, SCM elicitation or refusal the caller must act on, and
  rebuilding it to add a note would risk dropping the isError flag and hints that are the answer.
* `SkippedNotCompiled { compile_requested }` — two different sentences, because "you did not ask
  for a compile" and "the compile produced nothing" are different states and need different
  fixes.

Keyed off the payload's `compiled`, not off the mode, so it needs no list of modes to keep in
step with `DocMode`.

The write's verdict and the test's verdict stay separate (#310): `success` stays the WRITE's —
the document landed — and the test arrives as `test` with `test_ok` beside it. A red test must
not read as a failed write, and a failed write must not read as a red test.

Sequenced in the `iris_doc` tool method rather than inside `doc::handle_iris_doc`, because
running a suite is `iris_test`'s own 600-line job (polling, the result query, the per-case
shaping of #233/#273). Calling the tool means the two paths cannot disagree about what a red
test looks like.

89 of the measured `put` -> `iris_compile` pairs are a put that did not pass `compile`, followed
by a compile of the SAME class. Issue 327 records that as a 3% tail and proposes the cheap
option rather than flipping the default: have the put say so.

The no-compile branch carried NO `compiled` key at all, so "was it compiled?" was answerable
only by noticing an absence — and an absence reads as "does not apply here", not as "no". All
three write outcomes now report `compiled` AND `compile_requested`, so the states are distinct
from those two fields alone: (false,false) written and nobody asked, (false,true) the compile ran
and failed, (true,true) compiled clean.

fmt, clippy `-D warnings`, `cargo build --workspace`, and the gate with IRIS env unset. 15 new
tests. 10 mutations applied one at a time — including "a blank test counts as a request", "a red
test rewrites the write's own verdict", "iris_doc stops handing its result to the sequencer" and
"the sequencer stops calling iris_test" — each red on its named tests, restored green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(iris_doc): four TestGate messages carried leaked indentation

Ran this PR's test-after-put against a real instance for the first time. The behaviour is right in all
five cases — a green suite gives test_ok: true; a RED suite gives test_ok: false while the write's own
success stays true, which is the #310 separation working live; no compile gives test_skipped; a compile
failure gives COMPILE_ERROR with the write envelope untouched and no test block; and no `test`
parameter adds nothing at all.

What was wrong is the text. The skip message came back as:

  not run: `test` needs a COMPILED class and this call did not compile. Pass                  compile=true

Four messages, all in this PR: the three TestGate::skipped_reason arms and the unparseable-result
note. Same collapsed line-continuation as the seven fixed in #376/#377 — I fixed those on the branches
I happened to be working in and did not check the others.

## The scan that found the rest, and the one that did not

Running the earlier detector over every branch was useless: master-descended branches report 15-40 hits
each, because most matches are whitespace test fixtures, embedded ObjectScript whose indentation is
deliberate, and aligned doc-comment tables. No conclusion is available from those numbers.

Scoping it to the lines a branch ADDS (`git diff origin/master...origin/<br>`, `^+` only) makes the
population precise. Across all fifteen open PR branches that gives four real leaks, all four here.
Everything else the scan flags on those branches is intentional: measurement tables in doc comments,
the deliberate `"ERROR #5002:   "` truncation fixture, the real IRIS SQLCODE text in #368's fixtures
(which genuinely contains double spaces), and the leaked string the #380 detector test asserts on.

Verified at runtime after the fix by driving the built binary: the skip message contains no double
space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PYDuquesnoy added a commit that referenced this pull request Sep 28, 2026
…entation (#380)

The narrow, sound half of issue 378. Seven caller-facing messages were found carrying runs of leaked
indentation — a Rust string continuation collapsed onto one source line keeps its whitespace — and
all seven are fixed in #376/#377. This is the gate that stops the same thing recurring on the one
surface where the population is complete.

## Why descriptions and not "messages"

#378 records that there is no cheap sound gate over "every string that can reach a caller":

* A SOURCE scan cannot distinguish a collapsed continuation from a correct one without exclusion
  rules for whitespace test fixtures, embedded ObjectScript whose indentation is deliberate, and
  aligned trailing comments. Measured: the naive version reported 592 hits across 68 files, every one
  a false positive, because it matched across lines and so read the source layout rather than the
  runtime string.
* Message CONSTANTS are a sound population but a tiny one — three in this crate — and would have
  caught none of the seven, which were literals inside functions.
* Messages built at call time are only reachable by triggering their paths.

Descriptions are the one population that is complete, wire-facing and free: every one goes to every
client on every tools/list, and advertised_tools() returns all of them with no IRIS connection. So
this asserts what it can assert exhaustively and says plainly that it is not the whole problem.

## What it checks, and its controls

All four toolsets, because a description can be advertised by one and not another. Two controls guard
against a vacuous pass: the tool list must hold at least 20 entries, and at least 20 must actually
carry a description — a zero from an empty list would otherwise read as clean.

The detector has its own test, using the real text as it came back from the tool before the fix. That
matters here specifically: the first version of this scan produced 592 false positives, and a version
tuned the other way would have produced zero real ones with nothing to show the difference.

## Verification

fmt, clippy -D warnings, cargo build --workspace, and the gate with IRIS env unset: 5 result lines, 0
FAILED, both tests confirmed by name, a fabricated name returning 0.

Two mutations, each red and restored:

* a collapsed continuation injected into iris_execute's real description — caught, naming the tool
  and reporting a 22-space run.
* the tool list truncated to empty — caught by the control, "advertised only 0 tools, so a clean
  result would prove nothing", rather than passing as clean.

Currently zero descriptions are affected, so this commit is green on the tree as it stands: it locks
in a property rather than fixing one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant