Skip to content

wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold) - #75

Open
mjerris wants to merge 86 commits into
mainfrom
wave6/ctor-dunder-fold
Open

wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold)#75
mjerris wants to merge 86 commits into
mainfrom
wave6/ctor-dunder-fold

Conversation

@mjerris

@mjerris mjerris commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Deletion-only pass. The shared signature diff (porting-sdk _is_folded_dunder_member,
branch wave6/ctor-dunder-fold) now excludes a __init__ member whenever the reference
publishes a construction entry for that class, per ALLOWLIST_DISCIPLINE.md §495
("ctor / dunder → EMISSION (exclude); never a surface capability difference"). §10's
construction node already compares the same capability by parameter name rather than
by position — the comparison that is actually meaningful for a Ruby keyword constructor
held against Python's positional-with-default. That makes a block of this port's ledger
entries dead weight: entries the diff no longer consults.

No source change. PORT_OMISSIONS.md and PORT_ADDITIONS.md are untouched (they belong
to a different tool with a hard dead-entry gate). No allowlist/omission/divergence entry
added.

Before / after counts

file before after delta
PORT_SIGNATURE_OMISSIONS.md (parsed entries) 133 103 -30
PORT_OMISSIONS.md untouched
PORT_ADDITIONS.md untouched

Line-level: 1 file changed, 8 insertions(+), 37 deletions(-). The 30 entries plus the
now-empty # Ruby **base spread section header; the 8 insertions replace header
category 4 (which documented the **base event-spread idiom) with a note stating the
fold, so the next reader does not re-add what the diff now folds.

What was deleted:

  • 22 signalwire.relay.event.*Event.__init__ base-spread entries — the entire
    "Ruby **base spread" section, now empty and removed
  • prefabs.receptionist.ReceptionistAgent.__init__, prefabs.survey.SurveyAgent.__init__
    (**_opts forwarding)
  • core.contexts.GatherInfo.__init__, core.contexts.GatherQuestion.__init__,
    rest.client.RestClient.__init__, pom.pom.PromptObjectModel.__init__,
    agents.bedrock.BedrockAgent.__init__

Excused-divergence delta

The fold's effect on the excused count and the ledger prune are two different
numbers
, because the fold continues before the excusal branch — a folded ctor is not
excused, it is not compared at all:

diff ledger excused divergences exit
pre-fold (66d351a^) pre-prune (HEAD) 1181 0
post-fold pre-prune (HEAD) 1157 0
post-fold pruned (this PR) 1157 0
pre-fold pruned (this PR) non-zero (drift)

So the fold drops excused 1181 → 1157 (-24, matching porting-sdk #125's measured
table for ruby), and the prune removes 30 now-unconsulted ledger lines without
moving the excused count — which is the correct signature of a dead-entry removal.

Row 4 is the mutual dependency, demonstrated live: the pruned ledger against the
unfolded diff reports real drift, e.g.

signalwire.relay.event.TapEvent.__init__:
  - param-count-mismatch: reference has 8 param(s), port has 5:
    reference=[...,'control_id','state','tap','device'] port=['control_id','state','tap','device','base']

Construction node UNCHANGED

__init__-as-a-member and §10 construction params are different contracts; the fold must
not trade a ledger entry for a blind spot. port_signatures.json's top-level
construction key, HEAD vs. after a fresh regen:

construction classes  HEAD: 147  NOW: 147
construction param totals HEAD: 498  NOW: 498
key sets identical: True
full construction node identical: True

A fresh python3 scripts/enumerate_signatures.py --out port_signatures.json (ruby's
enumerator takes --out) leaves the tree clean — mtime moved, git diff empty — so the
committed artifact was already current and nothing needed re-committing.

__init__ entries the rule does NOT cover — left LIVE

3 entries stay, deliberately untouched. Their classes have no construction entry in the
reference, so the member comparison is the only comparison there is:

signalwire.skills.api_ninjas_trivia.skill.ApiNinjasTriviaSkill.__init__      False
signalwire.skills.play_background_file.skill.PlayBackgroundFileSkill.__init__ False
signalwire.skills.weather_api.skill.WeatherApiSkill.__init__                  False

(False = cls_path in reference["construction"].) All three are reference-oracle gap
entries: the reference surface records the skill class but the signature oracle omits
its __init__. Ruby has zero non-__init__ dunder entries, so the fold's
outright-exclusion half retires nothing here.

Gate output

Signature diff, all three surface flags (post-fold diff + pruned ledger):

$ python3 ~/src/porting-sdk/scripts/diff_port_signatures.py \
    --reference ~/src/porting-sdk/python_signatures.json \
    --port-signatures ./port_signatures.json \
    --omissions ./PORT_SIGNATURE_OMISSIONS.md \
    --surface-omissions ./PORT_OMISSIONS.md \
    --surface-additions ./PORT_ADDITIONS.md
✓ signatures match (1528 reference symbols, 2527 port symbols, 1157 excused divergences).
EXIT=0

Full bash scripts/run-ci.shexit 0, every gate PASS:

==> running CI gates for signalwire-ruby (porting-sdk at /Users/michaeljerris/src/porting-sdk)
[TYPE-EROSION] ... PASS      [COORDINATED-REFS] ... PASS   [COORDINATED-PASS] ... PASS
[NO-CHEAT] ... PASS          [DOC-SURFACE] ... PASS        [ENV-VAR-CONSISTENCY] ... PASS
[WIRED-MODES] ... PASS       [DOC-CLI] ... PASS            [LEDGER] ... PASS
[DOC-TRUTH] ... PASS         [AI-CHAT] ... PASS            [PACKAGE] ... PASS
[PUBLIC-JARGON] ... PASS     [ROOT-HYGIENE] ... PASS       [DEAD-PUBLIC-ERROR] ... PASS
[SURFACE] surface parity suite (SIGNATURES/DRIFT/SURFACE-FRESH/SURFACE-DIFF/SEMVER-DIFF/
          GEN-TYPE-DEGENERACY/GEN-IDIOM) ... PASS
[LINT] ... PASS              [FMT] ... PASS                [TEST] ... PASS
[BEHAVIORAL] ... PASS        [GEN] ... PASS
==> CI PASS
RUNCI_EXIT=0

SURFACE (which contains SURFACE-DIFF, the gate that reds if PORT_OMISSIONS.md is
pruned on this fold's strength) passes — confirming the prune stayed in the signature
ledger only. Tree clean after the FMT gate; no autocorrection to fold into the commit.

Merge order: porting-sdk #125 FIRST. Until it merges (or PORTING_SDK_REF is
pinned), this PR's CI is red BY DESIGN — the fold and the prune are mutually dependent.

Coordinated-With: porting-sdk@wave6/ctor-dunder-fold

mjerris and others added 30 commits July 27, 2026 09:50
…ff fold)

The shared signature diff now EXCLUDES a `__init__` member whenever the
reference publishes a `construction` entry for that class (porting-sdk
`_is_folded_dunder_member`, ALLOWLIST_DISCIPLINE.md §495: "ctor / dunder →
EMISSION (exclude); never a surface capability difference"). §10's construction
node already compares the same capability BY PARAMETER NAME instead of by
position, which is the comparison that is actually meaningful for a Ruby
keyword constructor held against Python's positional-with-default.

That makes 30 of this port's 133 PORT_SIGNATURE_OMISSIONS entries dead weight —
entries the diff no longer consults. Deleted:

  * 22 `signalwire.relay.event.*Event.__init__` base-spread entries (the whole
    "Ruby **base spread" section, now empty and removed);
  * ReceptionistAgent / SurveyAgent `**_opts` forwarding;
  * GatherInfo / GatherQuestion / RestClient / PromptObjectModel /
    BedrockAgent keyword-constructor entries.

Category 4 of the header ("Ruby `**base` event spread") is replaced by a note
stating the fold, so the next reader does not re-add what the diff now folds.

3 `__init__` entries stay LIVE and are deliberately NOT touched — their classes
have no `construction` entry in the reference, so the member comparison is the
only comparison there is:

  signalwire.skills.api_ninjas_trivia.skill.ApiNinjasTriviaSkill.__init__
  signalwire.skills.play_background_file.skill.PlayBackgroundFileSkill.__init__
  signalwire.skills.weather_api.skill.WeatherApiSkill.__init__

Ledger 133 -> 103 entries. Construction node unchanged: 147 classes / 498
params, byte-identical to HEAD. Deletion only — no source change, no new
allowlist/omission entry, PORT_OMISSIONS.md and PORT_ADDITIONS.md untouched.

Merge order: porting-sdk #125 FIRST. Until it merges (or PORTING_SDK_REF is
pinned), this PR's CI is red BY DESIGN — the fold and the prune are mutually
dependent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
…ve_xpaths

The signature oracle now records 7 derived public __init__ attributes. Ruby
already carried 2 of them (SignalWireRestError#request_id, Action#completed);
this closes the remaining 5.

SWML::Service — @ssl_enabled / @ssl_cert_path / @ssl_key_path / @Domain were
already derived in #init_ssl_config (from the SWML_SSL_* + SWML_DOMAIN env
vars) and overridden by the matching #serve kwargs, but were write-only state.
Added to the existing attr_reader so a caller can inspect the effective TLS
posture without re-deriving it from the environment.

SpiderSkill#remove_xpaths — new prefilled list of the boilerplate elements
dropped before text extraction. #strip_html previously hardcoded regexes for
script/style only; it is now driven by the list, so nav/header/footer/aside/
noscript are dropped with their content (they were flattened to their inner
text before) and the list is a real per-instance knob rather than a decorative
accessor. Only the simple //tag form is compiled — Ruby ships no HTML tree
parser, so a more expressive expression is skipped rather than mis-applied.

Signature drift 27 -> 22; the 22 remaining are the pre-existing
RestClient.<resource> accessor drift from a separate task.

Tests: 3 TLS-config readback cases (defaults / env-derived / serve override,
no certs required) + 4 spider cases (prefilled list, full-subtree drop,
mutated list honoured, non-simple xpath skipped).
Full suite 2656 runs, 0 failures, 0 errors, 0 skips. FMT + LINT clean.
Committed artifact was generated at c72a0b2, the commit BEFORE the derived-attr work.
9dbbd25 regenerated port_signatures.json but not port_surface.json — exactly why the
signature axis read clean while the surface axis showed 5 missing.

scripts/enumerate_surface.rb needed ZERO changes: it already collects attr_reader
members and none of the 5 appear in SURFACE_MEMBER_DROPS. Running the existing
enumerator at the tree picked all 5 up.

Non-RestClient surface gaps 5 -> 0 (verified independently by the orchestrator).
SURFACE-DIFF 27 -> 22, all RestClient.<resource>. SIGNATURE gate byte-identical to
HEAD. SURFACE-FRESH --check now exit 0 (was red). Tests 2656 runs / 0 failures.
FMT + LINT clean.
Both Ruby enumerators walked DECLARED members only
(`public_instance_methods(false)` / `instance_methods(false)`), so any surface a
class reaches through `include` was invisible.

`RestClient` composes its 22 flat-resource / namespace-container accessors by
including the generated `Namespaces::Generated::ResourceTree`
(lib/signalwire/rest/rest_client.rb:42) rather than writing 22 `def`s. All 22
were therefore reported as missing against a reference that records them on
`RestClient` — 22 signature drifts AND 22 surface drifts. They were never
missing: `client.calling` / `client.fabric` / `client.video` have always worked.
The gap was the walker, not the port.

This is the Ruby analog of `_wired_base_attributes` in porting-sdk's reference
enumerator, and is scoped the same way: only modules already EXCLUDED from the
surface scan are lifted (a module that is its own audited symbol would be
double-counted), only SignalWire-owned modules, and `initialize` is skipped.

Also folds Ruby LANGUAGE-PROTOCOL hooks (to_s / to_json / hash / eql? /
deconstruct / deconstruct_keys). `Relay::Message` includes the excluded
`MessageSerialization` mixin, so an unscoped lift surfaced its JSON, equality,
and pattern-matching protocol methods as 6 port ADDITIONS. The reference records
none of them (it records `__repr__`, the Python side of the same idiom), so they
fold at the emitter — the same reasoning that already folds
`AIChatClient#inspect`/`#to_s` in SURFACE_MEMBER_DROPS. No ledger entry.

`ResourceTree` added to enumerate_signatures.py's EXCLUDED_RUBY_CLASSES so a
lifted module can never also be recorded as its own symbol.

Adds tests/rest/resource_tree_accessors_mock_test.rb: pins all 22 accessors
reachable + memoized on a live client, and three of them (addresses / fabric /
video) landing real journaled requests on the shared mock.

No omission, addition, or allow-list entry was added; the excused-divergence
count is unchanged at 1155. The drift closed because the enumerators now see
real, already-working matched surface.

SIGNATURES: 22 drifts -> 0 (exit 0, 1557 reference symbols, 2554 port symbols)
SURFACE:    22 drifts -> 0 (exit 0, 2637 symbols)
ruby's SECURE-DEFAULT dump still spoke the pre-2026-07-27 protocol —
{secure_default_true, wire_reflects_secure}, two booleans the PORT computed
by inspecting its own render. The differ never saw the wire, so it could not
see WHICH key carried the token; that is how java passed this gate green
while minting into meta_data_token with a tokenless web_hook_url. The differ
now rejects the legacy shape outright, so the gate was RED.

Migrate to the payload protocol (mirrors signalwire-php bin/secure-default-dump
d3cbc47):

  {"<fixture id>": {"secure_default_true": bool, "rendered": {<functions[] entry>}}}

  * secure_default_true is read back from the live registry (@tools[name][:secure]),
    never echoed from what this program passed to define_tool — an echoed input
    is incapable of ever failing.
  * rendered is the tool's own SWAIG.functions[] entry verbatim, with token
    VALUES replaced by the corpus placeholder <TOKEN> and every KEY and key path
    preserved. Redaction mirrors diff_port_secure_default.redact_entry so the
    differ's re-application is a fixed point.

The program makes no judgement about correctness; the differ derives
has_own_webhook and token_carrier from the keys.

Dump-only change — ruby's emitter was already correct on both halves
(agent_base.rb:2282-2295 appends __token to the secure tool's own webhook and
omits web_hook_url entirely when qp is empty, matching the reference guard at
agent_base.py:1085-1099).

Gate: EXIT=1 (2 FAIL, legacy protocol) -> EXIT=0 (PASS).
Mutation-tested both directions: token moved to meta_data_token reds with
"TOKEN IN THE WRONG PLACE"; a per-tool web_hook_url on the insecure tool reds
with "WEBHOOK SHAPE".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
The reference mints 15-minute tool tokens by default
(core/security/session_manager.py __init__, "default: 15 minutes"); this
port minted 60-minute ones. A stale tool token is a credential, so the
longer window is a security divergence, not a cosmetic one.

The port's OWN class docstring already advertised
`SessionManager.new(token_expiry_secs: 900)` — the doc and the code
disagreed, which is how the divergence survived review. Folding the code
makes the existing docstring true.

Only the DEFAULT changes. An explicit `token_expiry_secs:` still wins,
and AgentBase is unaffected: it passes the value explicitly
(agent_base.rb:153) and keeps its own 3600 default, which is what the
reference's AgentBase does too (core/agent_base.py:130). The four doc
mentions of 3600 all describe that AgentBase parameter and remain true.

Tested by asserting the default directly rather than an explicitly-passed
value — the latter cannot catch a drifted default, which is exactly the
hole this sat in. Mutating the default back to 3600 turns the new test
red (Expected: 900, Actual: 3600).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
The signature enumerator emitted `"default": null` for all 235 defaultable
params, so a defaults-comparison gate would have been VACUOUS for ruby --
passing on silence. That is the failure mode that let a 4x-too-long SWAIG
token replay window (token_expiry_secs 3600 vs the reference's 900) sit
unnoticed until a human spotted the number.

Ruby reflection cannot supply the value. Method#parameters reports that a
default EXISTS (:opt / :key) but never what it IS:

    class T; def m(a, b = 42, c: "hi"); end; end
    T.instance_method(:m).parameters  #=> [[:req, :a], [:opt, :b], [:key, :c]]

So the value has to come from the SOURCE. signature_dump.rb now parses every
lib/**/*.rb with Ripper (stdlib -- deliberately NOT the prism/parser gems,
which are present only transitively as rubocop deps and would be an undeclared
dev dep) and indexes each def node's parameter defaults by [realpath, line].

That pair is an EXACT join key: Method#source_location returns the file and
line of the `def` keyword, and Ripper's def node carries the same line. So the
join needs no class/method name matching and cannot mis-attribute a same-named
method on another class or one reached through include.

LITERALS ONLY, by design. Integers (incl. radix prefixes / underscores),
floats, true/false/nil, strings, symbols, and arrays/hashes whose elements are
themselves literals are recovered. A non-literal EXPRESSION has no static value
and is left unrecovered rather than evaluated or guessed:

  * named constants -- DEFAULT_VOICE, RecordFormat::WAV, STRING (10 params)
  * arithmetic -- max_file_size: 100 * 1024 * 1024 (1 param)

String interpolation is explicitly rejected: Ripper would otherwise hand back
the concatenated static fragments ("a#{x}b" -> "ab"), a FABRICATED value. A
wrong default is worse than a missing one -- it makes a correct port look
defective, and "fixing" the port to match could introduce a real defect.

Recovery: 1463 of 1474 defaultable params (11 unrecoverable, listed above).
At the emitted-signature level, non-null defaults go 0 -> 330.

Also documented as genuinely unrecoverable: the AI-Chat Struct.new(keyword_init:
true) value models. A Struct declares field NAMES only -- there is no per-field
default expression anywhere in source -- so `default: null` there is accurate,
not a placeholder.

Verified ADDITIVE-ONLY: stripping `default` from the before/after artifacts
leaves them structurally identical (param set, order, names, types, kinds,
required flags, returns all unmoved), and the count of params carrying a
`default` key is unchanged at 1648.

Spot-checked against source, not against the reference:
  SessionManager#initialize(token_expiry_secs)  900   -> 900 (number)
  SessionManager#initialize(secret_key)         nil   -> null
  ToolRegistry#define_tool(secure)              true  -> true
  ToolMixin#define_tool(secure)                 true  -> true
  WebhookMiddleware#initialize(trust_proxy)     false -> false

`define_tool(secure)` is worth noting: php and dotnet both emit `false` for
this param while their sources say `true`. Ruby emits `true`, matching both its
source (registry.rb:68, agent_base.rb:709) and the reference.

The rubocop Metrics exclusions are scoped to this one file and are SIZE/shape
only, per .rubocop.yml's stated discipline. The extractor's size is a 1:1
dispatch over Ruby's literal AST node types, and its branch count is entirely
shape-guarding -- each guard is a place it declines to invent a value it cannot
prove, which is the property that keeps it honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Self-inflicted in 1fa058c's rubocop-driven refactor: extracting the numeric
arms of ripper_literal into a shared `ripper_numeric(token, converter)` passed
`Integer` / `Float` as a converter and called `converter.call(token)`. Those
are Kernel METHODS, not procs — the constants name the CLASSES, which have no
#call — so every integer/float default raised NoMethodError, taking the whole
dump (and therefore the SIGNATURES gate) down with exit 1.

Split into ripper_integer / ripper_float, which call Kernel#Integer / #Float
directly. Behaviour is otherwise unchanged: 330 non-null defaults, including
the 23 int + 6 float ones this bug was destroying.

The committed port_signatures.json was NOT stale — a regen after this fix
reproduces it byte-for-byte (git diff empty), because 1fa058c's artifact was
generated before the refactor introduced the fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…nce parity)

Ruby's WebService.start defaulted to '127.0.0.1', so a ruby agent started with
defaults accepted LOOPBACK ONLY — a containerised or remote-hosted ruby agent
silently failed where every other port worked.

The reference (signalwire/signalwire/web/web_service.py:543) defaults to
"0.0.0.0" and carries an explicit deliberate-choice marker:

    host: str = "0.0.0.0",  # noqa: S104  # intended server default: listen on
                            # all interfaces (overridable)

so the wide bind is considered, not an oversight. Owner ruled to fold ruby to
match, accepting the widened default network exposure for fleet parity.

Verified line 79 is the live server-start path: start(host:) flows to
build_server -> WEBrick BindAddress with nothing overriding it in between.

- default folded; explicit start(host: ...) is unchanged and still honoured
- doc comment records the rationale so a future reader does not "harden" it back
- test_default_host_binds_all_interfaces exercises the DEFAULT path (start()
  called with no host: kwarg) and asserts both the WEBrick BindAddress config
  and the actual bound listener address — mutation-tested: reverting the default
  to '127.0.0.1' turns it RED
- test_explicit_host_overrides_default pins the override behaviour
- port_signatures.json regenerated (ruby enumerates real default VALUES since
  1fa058c, so the host value lives in the artifact)

run-ci: exit 0, 22/22 PASS (unchanged from baseline).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…zero

The unified drift checker (porting-sdk 90164e9) now compares `required` and
`default` on ALL params, not just __init__. That surfaced 61 ruby findings:
25 required-flip, 21 default-invented, 15 default-mismatch.

RESULT: required-flip 25->0, default-invented 21->0, default-mismatch 15->1.
The one remaining default-mismatch is positional collateral of a separate
param-count-mismatch (ruby's AgentBase#define_tool carries wait_file /
wait_file_loops, which the reference's ToolMixin.define_tool does not; they
shift is_typed_handler by two slots). Not this change's kind.

SOURCE FIXES — the handler/callback contract
--------------------------------------------
The reference declares the handler REQUIRED on every registration method, and
so does every other port (verified against ts/go/php/perl port_signatures).
Ruby declared them `&block`-only, which the enumerator records as an optional
`block` keyword — a caller could "register" nothing and silently get a dead
callback. These now take the callable as a REQUIRED positional, with the block
still accepted (and winning) for the idiomatic body form:

  RelayClient#on_call / #on_message, Call#on, Message#on_event,
  AgentBase#on_debug_event / #set_dynamic_config_callback,
  SWMLService#register_routing_callback, AgentServer#register_global_routing_callback

Ruby cannot let a block satisfy a required positional, so `client.on_call { }`
becomes `client.on_call(nil) { }`. That is the intended breaking consequence.

`register_routing_callback` additionally had its parameters in the WRONG ORDER:
ruby was `(path, &block)`, the reference is `(callback_fn, path="/sip")`.

SOURCE FIXES — port-invented overloads removed
----------------------------------------------
`add_language` and `add_pattern_hint` had made every reference-required
positional optional to support extra call shapes with no reference counterpart:

  * `add_language(config_hash)` and the braceless `add_language('name' => ...)`
    — the reference spells that capability #set_languages.
  * `add_pattern_hint(pattern, hint:, language:)` — this one also emitted a
    DIFFERENT wire shape (`{pattern, hint, language}` vs the reference's
    `{hint, pattern, replace, ignore_case}`), so it was invented wire surface.

Both are removed; name/code/voice and hint/pattern/replace are now required.

SOURCE FIXES — default values
-----------------------------
  * prompt_add_section / prompt_add_subsection `body`: nil -> '' (reference
    `body: str = ""`). agent_base.rb disagreed with the port's own
    core/agent/prompt/manager.rb, which was already correct. Ruby's '' is truthy
    where python's is falsy, so the emitters now omit an empty body explicitly —
    the wire is unchanged.
  * add_skill `params`: {} -> nil (reference `= None`), normalised to {} before
    the factory so no registered factory meets a nil.
  * RelayClient#dial: `timeout: 120, **kwargs` -> `tag:, max_duration:,
    dial_timeout:` — the reference's exact keywords, with the 120s fallback
    applied at the wait where python applies it, and max_duration promoted from
    a **kwargs passenger to an explicit keyword that reaches the wire only when
    truthy.
  * define_tool `parameters:`/`handler:` required on BOTH AgentBase and
    ToolRegistry (reference requires both).

Reverse-direction flips (port REQUIRED where the reference defaults):
add_answer_verb(config), on_function_call(raw_data), serve_static_files(route),
InfoGatherer handle_start/handle_submit(raw_data).

ENUMERATOR FIXES
----------------
1. signature_dump.rb dropped CONSTANT-reference defaults. `format:
   RecordFormat::WAV` is exactly as fixed as `format: 'wav'`, but the Ripper
   pass recorded null, reporting five correct FunctionResult params as drift
   (record_call format/direction, tap direction/codec, pay ai_response).
   Constants are now resolved against the defining module's namespace chain
   (Method#owner is the lexical scope Ruby itself would use), rejecting anything
   that is not a JSON-representable value rather than fabricating one.

2. enumerate_signatures.py's MIXIN_PROJECTIONS CLOBBERED a real method on the
   target class. Ruby declares its own PromptManager#define_contexts(contexts) —
   required, matching the reference's PromptManager — but the projection
   overwrote it with AgentBase's optional one, whose reference counterpart
   (PromptMixin) is deliberately optional. The two reference methods genuinely
   differ. A projection now FILLS only what the target is missing, while still
   CLAIMING every name it covers so the AgentBase donor copy is popped (popping
   only the filled ones stranded set_prompt_pom on AgentBase, where the
   reference has no counterpart).

VERIFICATION
------------
  * tests/reference_required_and_defaults_test.rb: 27 new tests pinning both
    properties. Required-ness is asserted on the SIGNATURE (Method#parameters
    :req vs :opt) as well as behaviourally — mutation proved the behavioural
    assertion alone is VACUOUS here, because these methods raise from their own
    guard whether the param is required or optional-with-nil.
  * Defaults are asserted with the argument OMITTED, never passed explicitly.
    Where the wire cannot distinguish two candidate defaults (body ''/nil,
    add_skill {}/nil), the declared default itself is asserted — again found by
    mutation, which survived the wire-level assertion.
  * 24 mutations run across all three kinds (22 source + 2 enumerator); all 24
    RED when reverted, GREEN when restored.
  * bash scripts/run-ci.sh -> exit 0, 22/22 PASS (baseline was 22/22).
  * 2696 tests, 0 failures, 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…the signature adapter

Retires the last rename-carried omission in PORT_SIGNATURE_OMISSIONS.md. Per the
standing rule, a rename is reconciled in the ADAPTER rename table, never an
omission — an omission is a permanent blind spot; a rename keeps comparing.

The omission's rationale said the shape "does not match 1:1" so the Ruby name was
carried signature-side. That was suppressing BOTH sides of the pair: `get_factory`
sat in EXCUSED while the reference's `get_skill_class` sat in live DRIFT as
missing-port. enumerate_surface.rb has renamed this surface-side all along
(SURFACE_METHOD_ALIASES); only the signature adapter lacked the mirror entry.

Same capability, verified on both sides: given a skill name, return the
instantiable handle for that skill or nil. Python returns the CLASS
(`type[SkillBase] | None`, instantiated `skill_class(agent, params)`,
skill_manager.py:48); Ruby's registry stores factory lambdas rather than classes,
so it returns the Proc (`factory.call(params)`, agent_base.rb:1303). Same single
argument, same nil-on-miss, same role at the sole call site in each port.

The rename also lets the reference-type projection attach the concrete type, so
`skill_name` sharpens from `any` to `string` — the comparison the omission was
preventing.

Measured with diff_port_signatures.py --omissions on both sides, both runs against
a freshly regenerated artifact (SETS, not counts):

  DRIFT    89 -> 88   removed: SkillRegistry.get_skill_class (missing-port)
  EXCUSED 1107 -> 1106 removed: SkillRegistry.get_factory (missing-reference)
  added: ZERO on both sides

The symbol now compares EQUAL rather than moving to another drift kind.
Param-property drift stays at ZERO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…tructs

PART 1 — the seven API-name -> wire-key remaps in relay/call.py.

An independent AST audit of the tracked reference (resolving each dict key
against its enclosing function's parameters, not grepping) confirms exactly
seven remap sites in signalwire/relay/call.py and no eighth:

    play()             media        -> "play"
    play_and_collect() media        -> "play"
    pay()              input_method -> "input"
    join_conference()  stream_obj   -> "stream"
    bind_digit()       bind_params  -> "params"
    ai()               ai_params    -> "params"
    amazon_bedrock()   ai_params    -> "params"

Ruby classification: play / play_and_collect name `media` positionally and
re-key it to "play" at the emitter (class i). The other five ride the verbatim
`**kwargs` bag, whose keys are stringified unchanged; the wire key collides
with no Ruby parameter name at any of the five, so each is reachable by writing
the wire key directly (class ii). No site is class (iii) — Ruby has no
unreachable knob here, because its bag-local variable is `params` while the bag
KEY namespace is separate.

Only 2 of the 7 keys were previously asserted on the wire (play,
play_and_collect). pay and ai had tests that never passed the remapped key at
all (`charge_amount` / `prompt` only), and join_conference / bind_digit /
amazon_bedrock had no RELAY wire test whatsoever. Adds RelayRemapWireKeyTest
pinning all seven.

Mutation-verified, and the FIRST ROUND OF THAT PROOF WAS ITSELF VACUOUS: renaming
the `play` key made the mock's schema validator reject the frame UPSTREAM of the
assertion (`-32602: 'play' is a required property`), so the test went red as an
ERROR with 0 assertions executed — red, but proving only that the mock validates.
Re-done with schema-VALID WRONG VALUES and across the whole test file rather than
a --name filter: all 7 now fail as clean FAILURES (0 errors) with the diff landing
on the remap assertion itself, plus honest sibling collateral where a pre-existing
test also covered the key.

PART 2 — the 4 credential drifts are a module-ROUTING gap, not a Struct one.

porting-sdk dcff742 made BasicCredentials/BearerCredentials real oracle classes,
surfacing 4 DRIFT + 6 SURFACE findings. The diagnosis carried in from the prior
lane — that enumerate_signatures.py cannot enumerate positional `Struct.new`
members — is wrong. signature_dump.rb enumerates both Structs fine, including
their field readers. The classes were lost afterwards, two different ways:

  * SIGNATURES: both are nested inside AuthHandler, so the default namespace
    derivation routed them to the FABRICATED modules
    signalwire.core.auth_handler.basic_credentials / .bearer_credentials, which
    exist nowhere in the reference — so every member silently failed to compare
    and landed in EXCUSED instead.
  * SURFACE: both sat in RUBY_EXCLUDED_CLASSES, correct while the reference had
    no counterpart, obsolete the moment dcff742 published one.

Fixed by routing both to signalwire.core.auth_handler in BOTH enumerators, and
folding the Struct machinery at the emitter the same way the keyword_init
AI-Chat Structs already are — a new POSITIONAL_STRUCT_FIELDS pass (the
positional counterpart of AI_CHAT_STRUCT_FIELDS, differing in param KIND and in
recording the fields required rather than defaulted) and the existing
oracle-gated field-accessor path on the surface side. Both verify the declared
fields against the reflected readers, so a real field drop still fails loud
rather than shrinking the surface silently — verified by deleting `scheme` and
watching the enumerator abort.

`BearerCredentials.scheme` was a GENUINE CAPABILITY GAP, not an enumerator
artifact: Ruby's Struct had only `:credentials`. FastAPI's
HTTPAuthorizationCredentials — which the reference's carrier mirrors — splits the
Authorization header on the first space into scheme + credentials. Implemented
rather than excused, which exposed a real defect in the Rack bearer path: it
built the carrier from `header[7..]` alone, so nothing carried the scheme.

Effect, measured as SETS with --omissions passed on BOTH sides:
  DRIFT    4 -> 0   (all four retired; zero new)
  EXCUSED  1167 -> 1154  (-13, every one a credential entry; zero new)
  SURFACE  6 missing -> 0  (gate exit 0)
Also deletes the 8 now-dead PORT_SIGNATURE_OMISSIONS entries, which named the
fabricated modules and covered machinery that is now folded at the emitter.

The committed port_signatures.json was NOT stale before this change: a fresh
regen was byte-identical to `git show HEAD:port_signatures.json`. The staleness
the prior lane found at c36ddd0 was already repaired by ea630da.

Generated paths (scope, not fixed here): ruby's generated calling.rb performs
the call_id -> "id" remap at the emitter at 35 sites, matching the reference's
35. Ruby needs no from_ -> "from" remap at all — `from` is not a Ruby reserved
word, so the parameter is simply named `from`.

Verification:
  bash scripts/run-tests.sh tests/relay/actions_mock_test.rb  -> 43 runs, 0 failures
  bash scripts/run-tests.sh tests/core_auth_handler_test.rb   -> 17 runs, 0 failures
  diff_port_signatures.py (all 3 allowlist flags)             -> exit 0
  diff_port_surface.py (both allowlist flags)                 -> exit 0
  bash scripts/run-format.sh                                  -> 1375 files, no offenses

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… __init__

porting-sdk 8828dd2 taught the oracle to record a SYNTHESIZED `__init__`, not
only a `def`-declared one, so python_surface.json now records the constructor of
the five value carriers this port models as Ruby Structs (ChatLog /
ChatResponse / ConversationInfo / BasicCredentials / BearerCredentials).

The surface enumerator could not express that. Those five take the
`generated_methodless_class?` path, which bypasses `enumerate_methods` — and
with it the `initialize` -> `__init__` rename — and matches the oracle's member
set against `public_instance_methods(false)`. A Struct's constructor is defined
on `Struct` itself, so it is neither in that list nor the class's own
`initialize`, and `oracle_gated_field_accessors` aborted the whole enumeration:

    generated model signalwire.core.auth_handler.BearerCredentials is missing
    oracle-recorded field reader(s) ["__init__"]

The constructor is REAL and reachable — `ChatLog.new(messages:, call_timeline:)`
constructs, `instance_method(:initialize).owner` is `Struct`, arity -1. So this
is a capability the port genuinely publishes and the enumerator was failing to
see, not a gap to excuse. `__init__` is partitioned out of the field-reader
check and resolved against the class's reachable constructor instead; a missing
one still aborts, so a value carrier that really lost its constructor fails
just as loud. The signature enumerator already did exactly this for the same
five classes (synth_positional_struct_inits / synth_ai_chat_struct_inits) —
the surface side simply lagged it.

port_surface.json regenerated: 5 symbols added, 0 removed, every one an
`__init__`; committed-blob `__init__` count 150 -> 155. SURFACE-DIFF goes from
5 unexcused missing symbols to a clean match (2648 symbols, 21 excused
omissions, 450 excused additions) with no ledger entry added.
Owner ruling 2026-07-28: the whole port fleet lands on a single unreleased
3.0.0. This port was at 3.2.0; nothing in the 3.x range was ever published
(ruby's published tags top out at v2.0.0), so the downgrade regresses no
artifact. The credential-carrier work removed a public 2-arg
verify_basic_auth(username, password) across the OO ports — genuinely
breaking — and the fleet absorbs that into one coordinated 3.0.0 rather
than staggering majors.

Exactly one declaration site: SignalWire::VERSION in lib/signalwire/version.rb.
signalwire-sdk.gemspec require_relative's that file and assigns
`s.version = SignalWire::VERSION`, so the constant is the thing to change —
editing a gemspec literal would be wrong because there isn't one. Both
User-Agent strings (REST http_client, ai_chat client) interpolate the same
constant, as does agent_server's health payload, so no UA literal needs
editing and tests/rest/user_agent_mock_test.rb keeps asserting against the
constant.

Gemfile.lock self-references the version but is gitignored (a local bundler
artifact, regenerated on `bundle install`), so it is not a declaration site
and is not committed. The CHANGELOG's "## 3.2.0" heading is a historical
release entry, not a declaration site.

This sets version INTENT only. No tag, no release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ce payload

Per the owner ruling (2026-07-28) every port's release floor becomes 3.0.0.
`port_signatures.baseline.json` gets `baseline_version = 3.0.0` AND its recorded
surface payload replaced with the current enumeration.

Both halves are required. SEMVER-DIFF does not merely compare version numbers —
it diffs the surface against the floor's recorded `modules` payload, so bumping
`baseline_version` alone leaves `required = 'major'` against a floor whose payload
still describes an older surface. Before this change the floor reported:

    3.0.2 (3.0.2) -> 3.0.0  actual bump = 'downgrade', required = 'major'  [MISMATCH]
      BREAKING — 21 member(s) removed since last release

After the payload swap all 21 breaking removals are gone:

    3.0.0 (3.0.0) -> 3.0.0 (lib/signalwire/version.rb)  actual bump = 'none'
      no public surface change since last release.

This is a PAYLOAD SWAP, not a file copy: the floor carries release-anchor
metadata the current artifact does not. `modules` + `construction` come from a
FRESH `python3 scripts/enumerate_signatures.py` (103 -> 105 modules);
`baseline_version` is set to 3.0.0; and `generated_from_commit` is re-pointed
from the stale anchor to this branch's HEAD 7de2304. No v3.0.0 tag exists yet,
so a bare 40-hex commit sha is the correct anchor.

Semantics: the floor stops being "the surface as last published" and becomes
"the surface as of the 3.0.0 wave". That is coherent because nothing 3.x/4.x
ever shipped. SEMVER-DIFF will no longer flag anything already in today's
surface; future breaking changes are still caught, measured against the new floor.

FOUND WHILE REGENERATING — the committed `port_signatures.json` is STALE and is
NOT fixed here (left uncommitted for the lane that owns that artifact). A fresh
enumerate differs from the committed blob in 15 params, all of them type
REFINEMENTS the committed blob never picked up:

    handler: class:...relay.call.EventHandler   -> callable<list<class:...relay.event.RelayEvent>,void>
    handler: class:...relay.client.CallHandler  -> callable<list<class:...relay.call.Call>,void>
    handler: class:...relay.client.MessageHandler -> callable<list<class:...relay.message.Message>,void>
    agent:   any                                -> class:signalwire.core.agent_base.AgentBase   (x3)
    (+ `any` -> optional<dict<string,any>>, x3)

This is the "stale blob passes run-ci while masking real drift" failure mode.
It is NOT caused by the concurrent lane's uncommitted session_manager.rb —
proven by regenerating with that file stashed and getting a byte-identical
result, and by the drift touching zero session_manager symbols. THIS floor
records the FRESH surface, not the stale blob (verified by content:
floor.modules == fresh regen, != HEAD's committed blob).

KNOWN, PRE-EXISTING, NOT INTRODUCED HERE: run standalone (without --report-only)
semver_diff still exits 1 with `required = 'patch'` despite reporting "no public
surface change". That is a defect in the checker, not in this floor:
semver_diff.py:495 fires on `if allow:` — the mere EXISTENCE of a non-empty
SEMVER_DIFF_ALLOW.md — and overwrites an already-correct `required = 'none'` with
'patch', even when none of the allowlisted symbols appear in the diff. This port's
single allowlist entry (HttpClient.__init__) is pre-existing and unrelated to the
current diff. run-ci is unaffected: it invokes SEMVER-DIFF with --report-only
(the D5 "re-anchor at cut" wave setting), which exits 0. Reported for the owner
rather than papered over; no allowlist entry was added.

run-ci: real exit 0, 22 gates PASS / 0 FAIL — UNCHANGED from the pre-change
baseline. No gate moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… the Basic colon

Two divergences from the reference (fastapi.security.http), both in ruby's
hand-rolled Authorization guards. The reference's
`get_authorization_scheme_param` partitions the header on the FIRST SPACE and
each scheme compares `scheme.lower()` against its literal; `HTTPBasic` then does
`username, separator, password = data.partition(":")` and raises when
`separator` is empty.

(a) CASE-SENSITIVE SCHEME COMPARISON. `auth_handler.rb` matched the scheme with
    a fixed-offset slice -- `header.start_with?('Bearer ')` + `header[6..]` --
    which both hardcoded the scheme's length and could not match a lowercase
    token. RFC 7235 makes the auth-scheme token case-insensitive, so
    `authorization: bearer <token>` was authenticated by the reference and 401'd
    here. Same shape in `web_service.rb#credentials_match?`.

(b) MISSING-COLON BASIC PAYLOAD ACCEPTED WITH AN EMPTY PASSWORD.
    `auth_handler.rb#parse_basic_auth` destructured the separator into `_sep`
    and discarded it, so `Basic <base64("bob")>` -- no colon at all -- parsed as
    username "bob" with a defaulted empty password and constructed valid
    credentials. `web_service.rb` had the identical shape.
    `swml/service.rb#decode_basic_auth` used `split(':', 2)`, returning the
    1-element ['bob']; the 401 there came from a downstream nil-guard rather
    than the parse, so the outcome was right but incidental.

All three now split on the first space, fold the scheme for comparison while
carrying it verbatim into the carrier, and reject a decoded payload with no
colon. The two Rack middlewares (AgentTimingSafeBasicAuth /
TimingSafeBasicAuth) delegate to Rack::Auth::Basic::Request, which already
downcases the scheme and requires `credentials.length == 2`; verified correct
on both points, unchanged.

Tests assert both directions: lowercase/mixed-case `basic`/`bearer` are
accepted, and Digest/Negotiate/Basicx/Bearer-on-the-basic-branch/scheme-less
headers are still rejected -- the rejection cases pass before and after the
fix, so the accept assertions are not vacuous. A colon-less payload is
rejected, an explicit trailing colon is still a valid empty password, and a
password containing colons keeps everything after the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Per the owner's 2026-07-28 ruling, every CHANGELOG heading above a port's last
genuinely published tag collapses into a single unreleased 3.0.0 entry.

The Ruby port's published tags top out at v2.0.0 (verified against `git ls-remote
--tags origin`, not just local tags), so the 3.2.0 / 3.1.0 / 3.0.2 headings were
all unreleased drafts -- no released artifact regresses. They are consolidated
into one `## 3.0.0` entry with NO content dropped: all twelve bullets survive
under the merged Added / Fixed sections, with the two REST-resource additions
(client.projects, client.messages) folded in next to the spec-generated REST
surface bullet they extend.

lib/signalwire/version.rb already declares 3.0.0; this is the static mirror that
META-CONSISTENT cross-checks against it:

  meta_consistent.py --port ruby   exit 1 -> exit 0
  (was: manifest version '3.0.0' != top CHANGELOG entry '3.2.0')

This sets version INTENT only -- no tag, no release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Owner ruling 2026-07-28. DOCUMENTATION ONLY: no gate reads this file and nothing
fails on its presence. It records why the version looks the way it does, so the
next session does not re-derive it or casually bump one — and it carries the
delete-before-release checklist in its own body.

Nothing 3.x/4.x was ever published (git ls-remote tops out at v1.1.2 for
rust/dotnet, v2.0.x for most others), so the freeze rewrote no real history.

Exempted in porting-sdk root_hygiene.py 287b7f2.
…re TOKEN-INTEROP

The token envelope used Base64.urlsafe_encode64(token_raw, padding: false). The
reference keeps the '=' padding and its validator RAISES on a stripped '=' — so
every token this port minted was unusable to the reference and to any port that
decodes strictly, even with a correct key and a correct HMAC. In production every
secure tool call fails authentication.

create_session keeps padding: false — it mirrors secrets.token_urlsafe, which is
genuinely unpadded. Only the token envelope changed.

port_signatures.json is a fresh regeneration: the enumerator now records the relay
handler params as callable<...> rather than a dangling class: ref to the
EventHandler/CallHandler type ALIASES.

Also wires the TOKEN-INTEROP gate (property 3 of the SWAIG tool-token contract: a
token this port MINTS validates under the REFERENCE's own decoder). SECURE-DEFAULT
proves a token is minted and the fleet keying check proves the HMAC key; NEITHER
sees the base64 ENVELOPE, so a port can ship correct-key correct-HMAC tokens that no
other implementation accepts. Per-PR rather than nightly — a security property
should not wait.

Seven of the ten ports shipped an unpadded envelope, invisible to each port's own
tests because every port's DECODER tolerates missing padding while the reference's
urlsafe_b64decode RAISES on it — so round-tripping against ourselves could never
catch it. That is why the gate validates against the reference's decoder.

Verified: TOKEN-INTEROP exit 0; reverting padding: false on the envelope reproduces "urlsafe_b64decode raised Error('Incorrect padding')", so the gate fails for the right reason. rake test 2728 runs / 7662 assertions, 0 failures. Full run-ci PASS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…differ

porting-sdk 7034c33 stopped TYPE-EROSION from counting a MISALIGNED slot as an erased
type. The gate keyed on position, which is only meaningful while both param lists
describe the same parameters; where a port's list has a different SHAPE (different
arity, or a variadic catch-all standing in for a named param) index i was a different
parameter on each side, and an `any` there was reported as an erased type. Those methods
are already reported — correctly — by diff_port_signatures as param-count-mismatch.

So this port's old ratchet banked a number that was part real erosion and part
double-billed count-mismatch. Re-baselined onto what the corrected differ measures.

    ratchet 97 -> 16  (the delta is measurement correction, not a surface change)

No port code changed and no erosion was fixed by this commit: the number moves because
the MEASUREMENT was corrected, not because the surface improved. The ratchet doctrine is
unchanged — drive it DOWN, never up — and it now ratchets against a number that means
one thing.

Fleet-wide the same correction takes 524 -> 257; 292 of the 524 were the artifact. The
skip is never silent: each run prints how many methods went unmeasured and names the
gate that owns them.

Verified: diff_port_type_erosion.py --port ruby --repo . --max 16 -> exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
ruby's `strip_control_chars` already matched the reference's contract exactly —
event map in, string values scrubbed, non-strings untouched. It also had ZERO call
sites. `Logging::Logger#log` wrote the caller's message straight to `@output`, so
a NUL, a BEL, or an ESC-[ escape reached the terminal verbatim and could forge log
lines. The reference registers this scrub in BOTH of its structlog processor chains
(logging_config.py:205,233); here it was a method nobody invoked.

That makes ruby the clearest case in the fleet of why a correct signature is not
protection: rust/cpp/java each had the WRONG shape AND no callers, and fixing their
shape alone would have turned every gate green while leaving the emitter exposed.
ruby had the RIGHT shape and no callers, and was equally exposed. The signature was
never the thing standing between a caller and a forged log line.

  strip_control_chars_value   NEW internal per-value scrub (the unit the emitter
                              needs; strip_control_chars now delegates to it).
                              Not port surface — the public contract is unchanged.
  Logger#log                  now scrubs before `@output.puts`

THE TEST IS THE POINT, and ruby had NONE — no logging test file existed at all, and
nothing anywhere referenced strip_control_chars. New tests/core_logging_config_test.rb
(matching the core_* convention for signalwire/core/ modules) covers the public map
contract AND the wiring. The wiring test drives the real Logger and reads what it
wrote, so removing the scrub turns it RED:

    NUL survived into the emitted line:
    "[2026-07-28 22:50:52] INFO [inject.test] usersaid\e[31mRED\a\n"

A test that called strip_control_chars directly passes against that same break.

Two harness details the test has to respect, both learned the hard way in sibling
ports: the suite sets SIGNALWIRE_LOG_MODE=off process-wide (so the capture helper
forces the level for its block and restores it), and the logger writes to $stderr
(so that is swapped and restored in the same ensure). Neither leaks to a concurrent
sibling.

Also asserts tab/newline/CR SURVIVE — a scrub that ate them would satisfy "no
control chars" while mangling every multi-line message.

Verified: run-tests.sh -> exit 0, 2731 runs, 7678 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… emitter through the map contract

d6a1261 put the control-char scrub on the emission path via a NEW
`strip_control_chars_value`. That method is surface the reference does not have,
and the nightly surface gate said so:

    modules.signalwire.core.logging_config.functions[5]:
      committed='<absent>'  fresh='strip_control_chars_value'

It is a nightly-tier gate, which is why the per-PR run-ci was green locally. The
doc-comment I wrote even claimed "Not port surface" — but a comment is not a
mechanism, and nothing enforced it.

The rule is not to project or allow-list an invented symbol, it is to not invent
one. The emitter now goes through the reference's own event-map contract — one key
in, one key out — and the extra method is DELETED. The scrub body moved back inline
into `strip_control_chars`, so there is one implementation and one public entry
point, matching the reference.

  strip_control_chars_value   DELETED (was port-only surface)
  strip_control_chars         scrubs inline; unchanged contract
  Logger#log                  strip_control_chars('event' => msg.to_s)['event']

The wiring test still fails when the scrub is removed — re-verified after the
refactor, so the guard is not now vacuous. The failure names the surviving NUL and
prints the raw emitted line.

Also extracted `with_stderr_and_level` out of the test's capture_log. That was a
REAL pre-existing rubocop offense from d6a1261 (Metrics/MethodLength 12/10) which
the nightly LINT gate surfaces and the cheap wave does not — confirmed pre-existing
by re-running lint with this commit's source changes stashed.

Verified: run-tests.sh -> exit 0, 2731 runs / 7681 assertions, 0 failures.
run-lint.sh -> 1376 files inspected, no offenses detected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… examples it missed

SNIPPET-RUN (10 failures) and EXAMPLES-RUN (2 crashes) were red for ONE shared cause, and it
is stale docs, not an SDK defect. No source changed.

c36ddd0 ("fix(parity): burn required-flip/default-invented/default-mismatch to zero") made the
handler a REQUIRED POSITIONAL on the registration methods, matching the reference and the
oracle (python_signatures.json records handler required:true). Ruby cannot let a block satisfy
a required positional, so `client.on_call { }` became `client.on_call(nil) { }` — the commit
message calls this "the intended breaking consequence". That commit updated lib/, tests/ and
examples/ but MISSED docs/, relay/docs/, relay/README.md and relay/examples/.

  SNIPPET-RUN   9 stale snippets raising `missing keyword: :handler` /
                `wrong number of arguments (given 0, expected 1)`
  EXAMPLES-RUN  relay/examples/relay_answer_and_welcome.rb and relay_ivr_connect.rb still used
                `client.on_call do` — the relay/examples/ subtree was missed entirely

A SECOND DEFECT THAT NO GATE WOULD HAVE CAUGHT. c36ddd0 also REORDERED
register_routing_callback from `(path, &block)` to `(callback_fn, path = '/sip', &block)`. Five
doc sites still showed `register_routing_callback('/customer') do |...|`, which does NOT raise:
it binds the path string into the callback_fn slot and silently leaves path at '/sip'. Anyone
copying that doc registered a callback on the WRONG PATH with no error. Fixed at all 5 sites.
(Same callback-vs-path confusion found in go, dotnet and cpp this session — ruby is the only
one where it fails silently instead of at compile time.)

Also corrected 5 stale `def` signature blocks in api_reference.md against real source —
define_tool documented `secure: false` where the code says `true`, and was missing wait_file /
wait_file_loops.

SNIPPET_RUN_ALLOW.md: two EXISTING human-approved entries re-synced 131->132 and 166->167 after
a README edit shifted the fences. No new entries, rationales unchanged, approvals intact
(burn-ruby, 2026-07-09). Note these have now been re-synced twice — the allowlist keys on
path:LINE, which drifts under ordinary doc edits; tracked separately as a gate-design issue.

Verification (independently re-run, not the lane's word):
  sw-verify ruby --gates SNIPPET-RUN,EXAMPLES-RUN -> exit 0
    SNIPPET-RUN   PASS      (was 10 failed / exit 1)
    EXAMPLES-RUN  PASS      (was 36 server-started / 2 CRASHED / exit 1)
    NO-LAUNDER    PASS      (impossible: 15 · approved: 4 · idiom: 1 · unclassified: 0 · banned: 0)
  The lane's own run recorded 60 ran (22 exit0, 38 server-started, 0 CRASHED) — the 36->38 is
  the two fixed examples now genuinely STARTING, not merely failing to crash.
  run-format.sh -> exit 0, 1376 files inspected, no offenses, no tree changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…exists

Part of the fleet-wide false-ledger sweep. Both entries excused a divergence the source does
not have, and both deletions are drift-neutral (proof the symbols matched all along and the
entries were pure blind spots).

  RelayClient.dial
    claimed: "Python collects late args via **var_keyword"
    actual:  dial(self, devices, *, tag=None, max_duration=None, dial_timeout=None) — there is
             no **kwargs; the oracle records all three as `keyword`. Ruby's
             dial(devices, tag:, max_duration:, dial_timeout:) matched from the start.

  logging_config.strip_control_chars
    claimed: Python has the 3-arg structlog processor signature (logger, method_name, event_dict)
    actual:  Python takes ONE parameter. Its docstring states the design explicitly — the public
             function is kept to one parameter and the structlog plumbing confined to the two
             registration sites. The reference was deliberately built to NOT have the shape this
             entry attributes to it.

An omission excuses the WHOLE symbol from comparison, so each of these was a permanent blind
spot: a future real change to either would have gone unseen.

Deleted outright, not commented out — a tombstone keeps the symbol name and the false claim
greppable, so an audit of "which symbols are excused" gets a false positive. Git history is the
record.

Verification:
  sw-verify ruby --gates SURFACE,LEDGER -> exit 0
    SURFACE PASS · LEDGER PASS
    NO-LAUNDER PASS (impossible: 15 · approved: 4 · idiom: 1 · unclassified: 0 · banned: 0)
  Drift-neutrality established against a stashed baseline, not just an after-state run.

NOTE: the identical false strip_control_chars claim was also found and deleted in perl. False in
2 of 2 ports that carried it — worth grepping for in the remaining ports.

NOT TOUCHED, for a human:
  - lib/signalwire/core/logging_config.rb:69 repeats the same false premise in a code comment
    (not gate-load-bearing).
  - PORT_OMISSIONS.md:134 SkillRegistry.get_skill_class is a RENAME recorded as an omission,
    which that file's own header forbids. Fixing it is an emitter change, not a deletion —
    belongs to the idiom-fold campaign.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…s written

CLAUDE.md's Tool Definition example omitted `handler:`, which c36ddd0 made a REQUIRED keyword.
Typed verbatim by a user (or an agent reading this file for instructions), it does not work:

  before:  ArgumentError: missing keyword: :handler
  after:   tool registered, exit 0

Both halves executed against the real SDK, not inferred — a scratch script outside the repo
requiring lib/signalwire and calling the example as written, then as corrected.

  source of truth: lib/signalwire/agent/agent_base.rb:720
                   def define_tool(name:, description:, parameters:, handler:, ...)
  The two lines above it already document the intended form: "a block-bodied tool states
  ``handler: nil`` and passes the block."

Also corrected the prose at line 89, which said SWAIG functions are defined "via `define_tool`
with block handlers". That is now only half true — the block is still accepted, but it is no
longer sufficient on its own. It names the required keyword instead.

c36ddd0 DID update README.md:57-64 and docs/api_reference.md:577-588 (both carry `handler: nil`).
CLAUDE.md was the one file the commit missed, so ruby's doc tree was otherwise consistent.

ROOT CAUSE OF THE CLASS, worth more than this one fix: this defect lives exactly where the
automated checker is not looking. typescript, java and cpp CLAUDE.md files carry
`<!-- snippet-setup -->` preambles and rust carries per-block `<!-- snippet: no-compile -->`
markers — those four are wired into porting-sdk's snippet_extract/compile/run. Ruby and perl
carry NO snippet markers at all, and ruby and perl are the only two ports where a fleet-wide
sweep of every CLAUDE.md found a broken example (18 executable examples checked, 2 wrong).
Extending snippet extraction to ruby's and perl's CLAUDE.md would have caught this mechanically
at c36ddd0 time. Filed separately; not done here.

The perl finding is worse and is NOT fixed in this commit (different repo): CLAUDE.md:188-191
passes `prompt => ...` to `add_context`, which takes a name only (Contexts.pm:754). It does not
die — the pair is silently swallowed and the context renders with no prompt at all.

Verification:
  scripts/run-format.sh -> exit 0, 1376 files inspected, no offenses; changed nothing.
  git status: CLAUDE.md only. No SDK source touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
TLS-VERIFY was offered to ruby by porting-sdk and absent from the BEHAVIORAL suite's --rules list,
so it had never executed in this port. It is a SECURITY property and should not be optional
per port. (ruby already scheduled CA-VAR, SECURE-DEFAULT and SECRET-SCRUB; TLS-VERIFY was the one
gap.)

BURNED TO ZERO BEFORE WIRING, per the standing rule: run standalone against the current tree
first — ruby TLS-VERIFY PASS. This adds coverage without adding a red.

TIER: BLOCKING (per-PR), measured not assumed — timed at 0s on this tree. Nothing here justifies
the nightly tier, where a regression would sit unseen until the next scheduled run.

The gate `desc` string is updated alongside the --rules list; a desc that enumerates rules it does
not schedule reads as coverage and is worse than no desc.

Verification — the full suite as CI will now run it, not the new rule in isolation:
    behavioral.py --port ruby --rules <the full 20-rule list from run-ci.sh>
    -> [BEHAVIORAL] all 20 rules PASS

Found by the fleet-wide gate-wiring audit (task #55); part of that item's tier-1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…counting private symbols

porting-sdk 481b435 corrected DOC-SURFACE's ruby decl regex, which matched a LEADING
UNDERSCORE and so counted `_name` methods as public surface. Ruby barely moves —
52.8% (1802/3412) -> 52.4% (1764/3366) — which is the expected shape: privacy in idiomatic
ruby is `private`, not a naming sigil, so there were few to exclude. Python is where the
same defect was actually distorting the number.

The floor is re-pinned rather than left at 51.8 so it describes the measurement the gate
now produces. Gate exit 0. Full rationale in 481b435.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
emit_methodless_class opened `module Core` / `module SwmlVerbsGenerated` (and
the relay / swaig / post-prompt equivalents) with no preceding comment, so 602
public declarations across 301 generated files counted as undocumented. The
namespace nesting is public surface; give each intermediate segment a one-line
doc comment at the emitter and regenerate.

DOC-SURFACE ruby: 52.4% -> 70.3% (1764 -> 2366 of 3366).
relay_event.rb (73 decls): every typed event's from_payload decoder,
initialize kwargs and private event_fields hook, naming the RELAY event each
class decodes and the wire keys behind the renamed accessors (DialEvent's
`call` -> #call_data, QueueEvent's `id`/`name` -> #queue_id/#queue_name,
RecordEvent's nested `record` fallback).

context_builder.rb (48 decls): the Step / Context setters, the to_h wire
serializers, and the validation passes, stating what each field means to the
runtime and what each validator rejects.

DOC-SURFACE ruby: 70.3% -> 73.9% (2487/3366).
The construction/init helpers, the bare-noun and `x=` accessor pairs, the
AI-config setters, the verb queue, the skill/MCP/SIP integration helpers, the
SWML render decomposition, and the three Rack middlewares. States what each
field means on the rendered AI verb (the `multilingual`/`languages`
exclusivity, the secure-tool `__token` on SWAIG webhooks, the redacted
password at startup, the 413/401 middleware behaviour).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mjerris and others added 30 commits July 30, 2026 05:03
…inted

Owner ruling 2026-07-30: "all the full directories should be linted and
formatted including tests examples and all ... examples and tests are shipping
code too, all at the levels the shipping code gets."

Drops every AllCops/Exclude entry except vendor/ (genuine third-party code we do
not own). That widens the FMT + LINT gates -- which already invoke bare
`rubocop` from the repo root -- over examples/, relay/examples/, rest/examples/,
the 12 bin/*-dump programs, and scripts/doc_wire_runner.rb. 81 files, 743
offenses, previously invisible to both gates.

The deleted comment blocks argued these were "tutorial-style ... not audited
library surface". That rationale was about PUBLIC-SURFACE AUDITING, not about
whether the code should be well-formed, and it is not a reason to hold code
below the bar the rest of the repo already meets. The same file already refuses
to exclude the generated trees for exactly this reason.

The per-cop suppressions (Metrics/ParameterLists, Lint/DuplicateBranch,
Lint/EmptyBlock's tests/ scope, etc.) are a different category -- one rule, a
stated reason the construct is correct there -- and are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…(743 -> 75)

`rubocop --autocorrect` (SAFE corrections only) over the newly-in-scope tree.
819 offenses corrected across 72 files; 743 -> 75 residual, all of which need a
judgement call and are handled in follow-up commits.

Every correction here is semantics-preserving by construction:
  * Layout/* (382 HashAlignment, 85 MultilineMethodCallIndentation, ...) --
    whitespace only.
  * Style/RescueStandardError -- `rescue => e` IS `rescue StandardError => e`.
  * Style/FetchEnvVar -- `ENV[name]` -> `ENV.fetch(name, nil)`, identical result
    including the nil-on-absent case (deliberately NOT `fetch(name)`, which
    would raise).
  * Style/StringLiterals, Style/NumericLiterals (-32002 -> -32_002),
    Style/RedundantParentheses, Style/ConditionalAssignment.
  * Lint/ScriptPermission -- 4 examples with a shebang got the +x bit they were
    already documented as needing.

No cop was disabled, no path re-excluded, no inline suppression added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
execute_datamap carried AbcSize 58.9/17 and CyclomaticComplexity 26/7 -- by far
the worst complexity in the newly-linted tree -- almost all of it the
`h['k'] || h[:k]` string-or-symbol dance repeated eleven times and two inlined
"find it or warn+exit" blocks.

Extracted `either/2` (the string-or-symbol read), `require_present/2` (the
find-or-die), `datamap_webhook`, `datamap_request`, `datamap_fetch`, and
`expand_placeholder` (one iteration of the `%{args.NAME}` scanner). No cop was
suppressed; the complexity was real and it decomposed.

Behavior is byte-identical: the harness was driven against a loopback HTTP
fixture for both DataMap skills (api_ninjas_trivia, weather_api) before and
after, and the emitted JSON matches exactly (host:port normalized).

One subtlety preserved deliberately: the original `out << v.to_s if v` made a
FALSY arg expand to nothing, so `%{args.missing}` vanishes rather than becoming
"false". expand_placeholder keeps that and says so in a comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Twelve of the thirteen were caused by the FLUENT-CHAIN ALIGNMENT, not by the
code: rubocop aligns `.method` continuations under the receiver, so a
`SignalWire::Swaig::FunctionResult.new(...)` receiver pushed every subsequent
line 40+ columns right and a normal-length argument list overflowed 120.

Fixed by binding the receiver first and chaining from column 2 -- which is both
shorter and easier to read. This is safe because every mutator in these builders
(DataMap#purpose/parameter/webhook/webhook_expressions,
FunctionResult#join_room/set_metadata/tap/...) mutates the receiver in place and
returns self, so the discarded chain result is irrelevant.

The other two were long string literals, split with `\` continuation
(concatenation verified to produce the identical string).

Verified, not assumed: for every rebound chain the emitted structure is
BYTE-IDENTICAL before vs after (703/745/1057/384/410/617 bytes across
datamap_demo, advanced_datamap_demo, room_and_sip_example, tap_example), and all
seven touched examples produce identical stdout under SIGNALWIRE_SUPPRESS_RUN
(timestamp + autogenerated-auth-UUID normalized).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Six sites, each a real (if small) improvement rather than a suppression:

* bin/secret-scrub-dump -- Style/FileOpen was pointing at a GENUINE LATENT FD
  LEAK. `capture = File.open(path, 'w')` sat on its own line, then `$stdout.dup`
  and `$stderr.dup` ran BEFORE the begin/ensure that owned `capture.close` -- so
  a raise from either dup leaked the descriptor. Moved to the block form
  (with_fds_captured), which closes it structurally, and split the client push
  into push_authorization_state.

* bin/relay-liveness-dump -- Lint/ShadowedException: `rescue EOFError, IOError`
  had a dead first arm (EOFError IS an IOError). Errno::ECONNRESET is a
  SystemCallError, not an IOError, and stays listed. Also hoisted the
  `%i[text binary]` literal out of the frame-read loop
  (Performance/CollectionLiteralInLoop) into a frozen constant.

* bin/state-dump -- Naming/MethodParameterName on `ig`; renamed to `gatherer`
  and folded the each/break scan into a `find(...)&.fetch(...) || {}`.

* bin/swml-dump -- Style/ReduceToHash: each_with_object -> to_h.
* bin/wait-liveness-dump + examples/simple_dynamic_agent.rb -- Style/SafeNavigation.

45 offenses remain, all Metrics/* (size + complexity), handled next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Metrics/* burn for three Layer-D dump programs, by extraction only -- no cop
suppressed, no per-file exclude added. The shipped lib/ tree already meets
MethodLength <= 10 across its whole surface with exactly ONE per-file exception,
so that is the demonstrated bar, not an aspiration.

  bin/swml-dump          extract/9-cyclo -> ai_verb + extract
  bin/wait-liveness-dump run_case/AbcSize-39 -> answered_call + arm_answerer +
                         await_action + measure_wait + run_case_isolated;
                         classify's early-return literal hoisted to a constant
  bin/secret-scrub-dump  with_fds_pointed_at split out of with_fds_captured;
                         drive_into split out of captured_output; the inbound
                         authorization.state frame hoisted to AUTHSTATE_EVENT

Every extraction preserves the exact semantics, including the two subtle ones:
measure_wait's `timed_out` is still true on BOTH the rescue path and the
returned-nil-event path, and swml-dump's ai_verb still yields nil when the 'ai'
key is present but nil.

Verified live, not by reading: the porting-sdk behavioral suite passes all four
rules that actually EXECUTE these programs --
BEHAVIORAL-SWML / WAIT-LIVENESS / SECRET-SCRUB / SECRET-SCRUB-LIVE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
15 Metrics offenses -> 0, all by extraction. The load-bearing change is a real
separation of concerns rather than a size trick:

  * NEW module WsFraming holds the pure WebSocket TRANSPORT -- upgrade
    handshake, read-one-message, write text/close frame, close quietly -- with
    zero fault knowledge. ControllableWsServer includes it and is now only the
    fault policy (125-line class -> under the limit as a side effect).
  * with_fault_server(fault) captures the skeleton every connect-level driver
    repeated verbatim: stand up the server, point a fresh client at it, run,
    then tear BOTH down in an ensure. Four drivers + probe_auth_raise collapsed
    onto it, so the teardown can no longer be forgotten in a new one.
  * main's ten hand-written `out[...] = drive_x` lines became a FIXTURES
    ordered hash of id -> driver lambda, consumed by transform_values. Adding a
    fixture is now one line and cannot skip the emit.
  * probe_dead_peer / probe_execute_outcome / await_reconnect /
    construction_argument_error / offer_inbound_call / send_auth_reject /
    drop_post_auth / close_quietly extracted from their oversized callers.

Verified live: the porting-sdk RELAY-LIVENESS behavioral rule -- which executes
this exact program against the differ's golden and structurally compares all ten
fixtures' classification maps -- PASSES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Five dump programs, extraction only:

  ai-chat-dump      run/47-lines-AbcSize-44.7 -> create_and_chat_steps +
                    lifecycle_steps + summarize_failed_step + error_step +
                    bool_step + short_error_type. Call ORDER is preserved (the
                    differ compares an ordered artifact): merge's receiver chain
                    and each hash literal evaluate left-to-right.
  envelope-dump     the 49-line top-level CORPUS block -> run_case + case_client
                    + arm_case_scenario + drive_case + issue_request +
                    journalled_request_count, driven by CORPUS.to_h. Also split
                    as_hash_body out of decode_body_error_code.
  http-dump         the SAME json-parse-with-raw-fallback was inlined twice
                    (observe_response and reduce_lambda); extracted once as
                    parse_json_or_raw.
  pagination-dump   main's three copy-pasted fresh-client blocks -> a FIXTURES
                    table; the fresh MockTest.client per fixture is now
                    structural instead of a convention main had to remember.
  secure-default-dump  the two inline A1 cases -> named methods.

Verified live: BEHAVIORAL-HTTP, ERROR-ENVELOPE, PAGINATION-WIRED,
PAGINATION-CORPUS, SECURE-DEFAULT all PASS, plus the AI-CHAT gate
(diff_port_ai_chat.py) -- "client speaks the AI Chat protocol per the vendored
spec".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
REAL BUG, found by putting relay/examples under the lint for the first time.

relay/examples/relay_ivr_connect.rb called

    call.play_and_collect(media: [...], collect: {...})

but Call#play_and_collect's signature is

    def play_and_collect(media, collect, volume: nil, control_id: nil, ...)

`media` and `collect` are POSITIONAL. Passing them as keywords sends both into
**kwargs and leaves the two required positionals unfilled, so the call raises

    ArgumentError: wrong number of arguments (given 0, expected 2)

on the very first thing the example does with an answered call. Anyone
following this IVR example hit an immediate crash.

Why it survived: relay/examples/ is explicitly excluded from
tests/examples_file_mode_smoke_test.rb (those files open a live WebSocket on
load, so they are not file-mode targets) AND was excluded from rubocop -- so the
directory had NO coverage of any kind. tests/relay/actions_mock_test.rb uses the
correct positional form throughout, which confirms the SDK is right and the
example was wrong.

The fix is one line. The test is the valuable part: a new
tests/relay_examples_arity_test.rb statically parses every relay/examples file
with Ripper, finds each `<recv>.<method>(...)` naming a public Relay::Call
method, and asserts the call supplies at least as many POSITIONAL arguments as
the method requires. Static parsing is what makes it runnable with no socket, so
relay/examples finally has a guard that runs in the normal suite.

RED before (against the shipped example):
  relay_ivr_connect.rb: play_and_collect needs 2 positional arg(s), example supplies 0
GREEN after: 1 runs, 5 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…75 -> 0)

The whole repo is now rubocop-clean at the SHIPPED bar (`bundle exec rubocop`
exit 0, 0 offenses in 1461 files) with vendor/ as the only directory exclusion.

The two dynamic-config agents were the bulk: a 75-line and a 58-line callback
block, each a straight-line wall doing six unrelated things to an ephemeral
agent copy. Both now read the query string once into a `profile` hash and call
named per-concern methods (apply_language / apply_tier_params /
apply_industry_prompts / apply_global_data / apply_debug_and_ab), with the
if/elsif/case ladders over department, industry and tier folded into frozen
lookup tables. Adding a tier is now a table row.

Also: swml_service_example's three near-identical example bodies share one
`example(banner, name:, route:, port:) { |service| ... }` frame;
swmlservice_ai_sidecar's and swmlservice_swaig_standalone's oversized
constructors split into build_document / register_* / mount_event_sink;
relay_ivr_connect's IVR block became collect_menu_digit + say_and_wait +
connect_to_agent + a case over the digit; doc_wire_runner's linear replay split
into three per-doc-region methods.

One correctness detail worth naming: apply_tier_params dups the frozen
TIER_PARAMS row before the test-group-B adjustment, because the original built a
fresh hash per request and then mutated it -- without the dup the constant would
be mutated across requests.

Verified, not assumed. For the two dynamic agents, the callback was driven over
a 22-combination query-param matrix covering every branch (tier x industry x
language x locale x test_group x debug) and the RENDERED SWML compared HEAD vs
working tree: 15602B and 16067B, byte-identical. For the two SWML::Service
subclasses, both the rendered document AND the registered SWAIG tool defs are
byte-identical (736B / 358B). All probes carry a size floor so an empty
artifact fails loudly instead of passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…g two cops

tests/core_security_config_test.rb:152 carried a bare

    # rubocop:disable Metrics/MethodLength, Metrics/AbcSize

with NO rationale -- the only unjustified inline suppression in the repo. Rather
than write one, the method genuinely decomposed: build_self_signed(key) does the
X509 construction, write_self_signed(dir) does the two File.writes. Both cops
are back ON for this file.

13 runs, 34 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…a parse failure

Second real defect, same class the dotnet/java/go lanes hit: a tool that feeds a
gate FAILING SUCCESSFULLY.

scripts/signature_dump.rb feeds scripts/enumerate_signatures.py, which writes
port_signatures.json -- what the cross-port DRIFT gate compares against the
Python oracle. Part of it is a "default index": every `def` under lib/ is parsed
with Ripper so literal parameter defaults can be recovered (349 non-null
defaults ride on it today). That parse was:

    def parse_file(path)
      Ripper.sexp(File.read(path))
    rescue StandardError
      nil
    end

and the caller did `next if sexp.nil?`. A file that would not parse or would not
read was skipped with NO diagnostic and NO exit code. The dump still exited 0 and
still looked well-formed -- it was just SHORTER. Those defaults came back null,
and DRIFT compared the port against a silently-shrunken surface.

The in-code comment argued the degradation was honest ("params then stay null,
i.e. honestly unrecovered rather than wrong"). It is not: every file under lib/
is valid Ruby by construction -- FMT, LINT and TEST all parse the whole tree --
so a failure here never means "not Ruby". It means truncated, unreadable, or a
broken reader. Silence is the one thing that must not happen.

parse_file now raises with the path. Ripper.sexp RETURNS NIL on a syntax error
rather than raising, so both the nil-return and the exception paths are turned
into a loud failure; the read errors are narrowed to SystemCallError/IOError so
a genuine bug in the walker is no longer caught here at all.

RED before: "StandardError expected but nothing was raised".
GREEN after: 2 runs, 6 assertions, 0 failures.

Not currently masking anything: port_signatures.json is byte-identical after the
change, so no lib/ file is being skipped today. The guard is prophylactic -- it
makes the failure impossible to miss rather than fixing a live shortfall.

Swept the sibling gate-input producers for the same shape:
scripts/route_registry.rb's `rescue StandardError, NotImplementedError` RETURNS
the error string into its `errors` list (loud), and
scripts/enumerate_surface.rb's `rescue NameError` is a narrow reflection guard.
Neither is a swallow-and-continue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The repo is a Ruby SDK, but scripts/ holds SEVEN hand-written Python programs:
the five code generators, the cross-port surface enumerator, and _gen_format.py
-- which is part of the FORMAT TOOLCHAIN ITSELF. No gate linted or
format-checked any of it (FMT/LINT run rubocop, which cannot see a .py file), so
this load-bearing tooling was held to no standard at all. Fleet-wide gap; ruby's
share is these 7 files.

ruff.toml MIRRORS the reference's config (signalwire-python/pyproject.toml
[tool.ruff]): same target-version py310, same line-length 88, same select list
(E4/E7/E9/F/B/S/C4/PERF/SIM/PTH/RET/RUF/UP), same explicit `preview = false`
format pin. Deliberately no per-file-ignores.

58 findings -> 0. Beyond the mechanical UP/RUF/PERF/SIM/RET fixes, three had
teeth:

  * F601 x3 -- RUBY_TO_PYTHON_MODULE_MAP in enumerate_signatures.py declared
    ChatResource, DatasphereDocuments and DatasphereNamespace TWICE. The values
    happened to match, so nothing was wrong today (proven: port_signatures.json
    is byte-identical after removing the dupes), but a dict literal with a
    repeated key is a live trap -- editing one copy silently loses to the other.
  * F841 x3 -- three dead locals, one of them meaningful:
    generate_rest.py's §9 update-verb check still carried
    `item = spec.doc["paths"][anchor]`, the leftover of the FLEET-WIDE §9 TRAP
    where that check inspected the COLLECTION path for put/patch and therefore
    never fired. The item_level=True lookup that fixed it was already there; the
    assignment was its corpse. Commented so it does not come back.
  * B007 x2 -- two unused loop variables renamed to _spec/_anchor.

Three subprocess sites (S603/S607) carry a per-line `# noqa` with a written
rationale: each is a fixed list-form arg vector under the default shell=False --
no shell to interpolate into -- and the only non-literal elements are paths the
module itself builds. Same disposition and same reasoning the reference applies
to its own subprocess calls. These are the ONLY suppressions added.

`ruff format` applied (7 files reformatted).

Verified, not assumed -- this is tooling that produces gate inputs, so output
identity is the bar:
  * enumerate_signatures.py: port_signatures.json byte-identical (349 non-null
    defaults still recovered).
  * All five generators pass `--check` (GEN-FRESH): generate_rest,
    generate_rest_tests, generate_relay_protocol, generate_swaig_payloads,
    generate_swml_verbs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Wired only now that BOTH burns are at zero (rubocop 743 -> 0, ruff 58 -> 0), so
neither gate lands red.

Ruby side: NO new gate. FMT and LINT already invoke bare `rubocop` from the repo
root, so cutting .rubocop.yml's AllCops/Exclude down to vendor/ widened them
automatically over examples/, relay/examples/, rest/examples/, the 12 bin/*-dump
programs and scripts/doc_wire_runner.rb. Adding a REPO-LINT/REPO-FMT pair would
have double-linted the same files; the gate descriptions and a run-ci comment now
say so explicitly so nobody adds one later. Also corrected the stale scope
comment in _env.sh, which still claimed examples/ were excluded.

Python side: new PY-LINT + PY-FMT gates over scripts/*.py, with
scripts/run-py-lint.sh and scripts/run-py-format.sh as the canonical entry
points -- same shape as run-lint.sh / run-format.sh, same contract for the format
gate (LOCAL APPLIES, CI RUNS --check), CWD-independent via _env.sh.

ruff is declared in BOTH layers, per the dev-dependency rule:
  * scripts/_env.sh: sw_ruff resolves `python3 -m ruff`, else the `ruff` binary
    on PATH, else FAILS LOUD with an install hint. Never a silent skip -- a
    skipping gate is a gate that passes vacuously.
  * the three ruby workflows that run run-ci.sh (test.yml, nightly.yml x2 jobs,
    publish.yml) each `pip install ruff`.

Negative-controlled rather than assumed: with a deliberately bad scripts/*.py
(unused imports + unformatted), run-py-lint.sh exits 1 and
run-py-format.sh --check exits 1 -- both invoked from a DIFFERENT working
directory, which also proves the repo-root resolution.

CROSS-REPO FOLLOW-UP (not editable from this lane): porting-sdk's
.github/workflows/cross-port.yml runs each port's run-ci.sh and does not install
ruff. The ruby matrix leg will hit _env.sh's fail-loud until `pip install ruff`
is added to its setup, alongside the existing mock_relay/mock_signalwire installs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…udes

Two gate failures the previous commits caused, both fixed at the source rather
than allowlisted.

ROOT-HYGIENE: a public port root must be free of tool config; the gate named the
remedy ("move to eng/"). ruff.toml -> eng/ruff.toml. Adding a
ROOT_HYGIENE_ALLOW.md entry instead would have needed human approval for a file
that has no reason to sit at the root.

That move exposed a REAL vacuous-pass risk, so sw_ruff now PINS
--config eng/ruff.toml: ruff only auto-discovers a config from the TARGET's
directory upward, so with the file at eng/ a bare `ruff check scripts/` silently
falls back to ruff's BUILT-IN defaults. Measured on a probe using shell=True plus
an unnecessary .keys() iteration -- built-in defaults find 0, this config finds 4.
The gate would have gone on passing while checking almost nothing. Negative-
controlled after the fix: a bad scripts/*.py still exits 1 through the runner.

README-INCLUDE: all three README code blocks are gate-enforced to be
BYTE-IDENTICAL to their `# region:` fixture in examples/. The rubocop autocorrect
reformatted those examples (Layout/ExtraSpacing collapsed `token:   ` to
`token: `, etc.), so the README drifted. Resynced all three blocks FROM the
fixtures, which is the correct direction -- the runnable example is the source of
truth and the doc mirrors it.

DOC-TRUTH now reports all 8 rules PASS (readme-include: clean, 3 sites verified).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Two process-global ENV writes lived inside `parallelize_me!` classes, so every
concurrently-running test observed an environment mutated by a test it does not
own. Both are fixed by SCOPING, not by serialising: no suite mutex was added, no
`parallelize_me!` was removed, and no ordering was forced.

1. `connect_mock_test.rb` -- `test_connect_rejects_empty_creds_at_constructor`
   `ENV.delete`d SIGNALWIRE_PROJECT_ID / _API_TOKEN / _JWT_TOKEN and restored
   them in an `ensure`. Under `parallelize_me!` that is a window in which the
   credential vars are absent for every other running thread, AND -- because the
   `ensure` restores them -- concurrent instances clobber each other's window.

   Demonstrated, not asserted: a minimal two-suite reproduction of this exact
   shape (one suite doing the delete/restore, one constructing a client that
   reads the ambient JWT) fails 12 of 40 runs.

   Fixed by INJECTION. No var actually needed clearing: `value_or_env` is
   `explicit || ENV[key]` and `''` is TRUTHY in Ruby, so the explicit
   `project:`/`token:`/`space:` already won outright. Only `jwt_token:` was
   unset and could fall back to an ambient SIGNALWIRE_JWT_TOKEN -- which
   short-circuits `validate_credentials` (client.rb:151) and would suppress the
   ArgumentError. Passing `jwt_token: ''` closes that channel by injection.

   Negative-controlled: with all three vars set ambient, the injected form still
   raises and the un-injected form does not -- so `jwt_token: ''` is load-bearing
   rather than decoration.

2. `mock_test.rb` -- `build_sdk_client` set SIGNALWIRE_RELAY_SCHEME and
   SIGNALWIRE_RELAY_HOST on EVERY client build, i.e. once per parallel test.
   Those two are the SDK's only ws:// redirect channel (client.rb:615/:625, no
   kwarg exists), so the VALUE must be process-global; the WRITE must not be.
   Moved into `Lifecycle.start_harness`, the `@mu`-guarded one-time startup that
   already computes the host -- written once, before any parallel suite starts,
   read-only thereafter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The `define_contexts` projection-table review (task #118) turned up a paired
omission+addition that is really one accessor-spelling rename.

FINDING 1 -- a retired omission. `PORT_SIGNATURE_OMISSIONS.md` excused
`PromptMixin.contexts` as "Ruby exposes contexts via define_contexts/internal
state, not a bare `contexts` accessor". Ruby DOES expose the read; it spells it
`get_contexts`. Same value, same source, different accessor name -- textbook
idiom, which Rule 2 folds at the enumerator. Added the rename to BOTH
`SURFACE_METHOD_ALIASES` (enumerate_surface.rb) and `SIG_METHOD_ALIASES`
(enumerate_signatures.py), keyed on PromptMixin only: the reference ALSO
declares a real `get_contexts` on PromptManager, which Ruby matches by that name
outright and must not be renamed. Verified both survive: PromptMixin.contexts
and PromptManager.get_contexts are each present and distinct after the fold.

The alias alone was inert -- `get_contexts` was not in the PromptMixin entry of
`MIXIN_PROJECTIONS`, and the alias pass runs AFTER projection, so a name that
never arrives can never be renamed. Added it to the projection list too.

Excused signature divergences 1176 -> 1175.

FINDING 2 -- a false rationale, corrected in place. `PORT_ADDITIONS.md`'s
`agentbase-family.get_contexts` was justified "port-only". It is not port-only:
the reference declares this read TWICE (PromptManager#get_contexts and the
PromptMixin.contexts property). Tested by removal -- the entry is still
load-bearing, because Ruby's mixin collapse puts a `get_contexts` on AgentBase
that the reference's AgentBase does not carry -- so it stays, but re-stated with
the accurate mixin-collapse reason its sibling entries already use, and scoped
to the AgentBase-hosted twin.

The `define_contexts` projection itself needed no change: the fill-not-clobber
rule is correct and the two reference `define_contexts` genuinely differ
(PromptManager required/void vs PromptMixin optional/union-return); ruby's
port_signatures records both with the matching required/optional split.

Both artifacts verified fresh by sha256 against a fresh regen, not by an empty
git diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
All nine entries assert a gap that the oracle does not have. Confirmed
symbol-by-symbol with query_signatures.py against python_signatures.json:

  BedrockAgent.set_inference_params      recorded (self, temperature?, top_p?, max_tokens?, ...) -> void
  BedrockAgent.set_llm_model             recorded (self, model: string) -> void
  BedrockAgent.set_llm_temperature       recorded (self, temperature: float) -> void
  BedrockAgent.set_post_prompt_llm_params recorded (self) -> void
  BedrockAgent.set_prompt_llm_params     recorded (self) -> void
  BedrockAgent.set_voice                 recorded (self, voice_id: string) -> void
  ApiNinjasTriviaSkill.__init__          recorded (self, agent: AgentBase, params: optional<dict<string,any>>) -> void
  PlayBackgroundFileSkill.__init__       recorded (same)
  WeatherApiSkill.__init__               recorded (same)

The three __init__ entries are stale copies of an already-fixed enumerator
bug (EXCEPTION 2, enumerate_python_signatures.py) — the entries outlived
their cause. The six BedrockAgent entries claim the oracle omits
signalwire.agents.bedrock; it does not.

Also drops the PORT_OMISSIONS.md category line "Bedrock (9 symbols under
signalwire.agents.bedrock.*): omitted." — false prose, not an anchored
entry (it excused nothing mechanically): the port DOES ship BedrockAgent
and port_signatures.json records it in full.

A/B with --omissions on both sides, compared as SETS:
  drift   7 -> 7   (no new, no gone)
  excused 1121 -> 1121

The excused count does not move because these entries were excusing NOTHING
— the oracle records each symbol and the port implements each symbol, so
there was never a divergence for them to cover. Pure dead paperwork.
…ture side

porting-sdk 8496c77 took the signature oracle from 7 of 18 skill modules to
18 of 18 (a per-method skip emptied classes whose every method was a
base-identical override, and an emptied class was dropped outright). The
newly-visible modules declare a `get_instance_key` OVERRIDE, and ruby drifted
on 7 of them.

Root cause: enumerate_surface.rb has carried SKILLS_MODULE_METHOD_ALIASES
(`instance_key` -> `get_instance_key`, applied by module prefix) since it was
written, but enumerate_signatures.py only ever had the single explicit
SIG_METHOD_ALIASES row for `SkillBase`. That sufficed while none of the 7
visible skill modules declared an override. It stopped sufficing the moment
all 18 were enumerated, and each un-renamed override drifted twice: a
missing-port for the reference name plus a missing-reference for the Ruby one.

ruby ships every one of these overrides at source
(lib/signalwire/skills/builtin/{claude_skills,datasphere,datasphere_serverless,
info_gatherer,native_vector_search,swml_transfer,web_search}.rb) — this was a
projection gap, never absent code. Fixed by mirroring the by-prefix rule into
the signature enumerator, so the two audits reconcile the SAME rename.

A/B with --omissions on both sides, compared as SETS:
  drift   7 -> 0    all 7 get_instance_key findings gone, none new
  excused 1121 -> 1114

The excused drop is 10 stale `<Skill>.instance_key` missing-reference entries
retiring, minus 3 that reappear under the reference name (see below).

RESIDUAL — an oracle hole 8496c77 did not close. Three classes are enumerated
but still under-enumerated: the oracle records only
  ApiNinjasTriviaSkill  -> [__init__, get_tools]
  PlayBackgroundFileSkill -> [__init__, get_tools]
  SpiderSkill           -> [__init__, remove_xpaths]
while the reference source declares get_instance_key on each
(signalwire/skills/api_ninjas_trivia/skill.py:146,
play_background_file/skill.py:138, spider/skill.py:201), along with setup /
register_tools. Ruby's overrides for these three therefore project to
`get_instance_key` and land as excused missing-reference additions carrying
the now-inaccurate "port-side state accessor (no Python counterpart)"
rationale. Not a ruby defect and not fixable from this repo — flagged for the
oracle. Deliberately NOT re-excused.

No omission or allow-list entry added.
…artext

#90's failure that matters is a client the user configured for TLS quietly
sending the request in the clear — encryption asked for, not delivered, never
reported. tests/tls already proved verified HTTPS + verified WSS and rejected
untrusted peers, but nothing covered the downgrade.

Three probes, all driving the REAL clients:

1. BEHAVIOURAL — a REST client with an https:// base_url pointed at a plain
   TCP listener that answers a valid HTTP 200. The listener captures the raw
   opening bytes, so a downgrade is caught as CLEARTEXT ON THE WIRE, not just
   as a missing exception. Observed: first byte 0x16 (TLS ClientHello), and
   SignalWireRestTransportError "SSL_connect ... wrong version number". No
   cleartext request line ever reaches the socket.

   Negative-controlled: patching build_http to skip configure_ssl (the exact
   silent-downgrade shape) turns this test RED ("SignalWireRestError expected
   but nothing was raised") and the listener sees the plaintext GET. Reverted.

2. REST default scheme is https:// for an ordinary space (no base_url, no env
   override) — TLS is the default, not an opt-in.

3. RELAY default scheme is wss:// with SIGNALWIRE_RELAY_SCHEME unset.

Findings from the wider audit, no code change needed:

* Certificate verification is on by default on every transport. REST
  configure_ssl hardcodes VERIFY_PEER with no VERIFY_NONE path; RELAY
  wss_tls_options forces VERIFY_PEER with a store seeded from the OpenSSL
  defaults (websocket-client-simple otherwise leaves a fresh SSLContext at
  VERIFY_NONE).
* The skill/ai_chat transports (spider, datasphere, native_vector_search,
  web_search, google_maps, ai_chat) set use_ssl and leave verify_mode unset.
  Verified behaviourally against a live self-signed TLS listener that this is
  NOT a hole: Net::HTTP with verify_mode nil rejects it — "certificate verify
  failed (self-signed certificate)".
* The one VERIFY_NONE in the tree (mcp_gateway) is an explicit operator
  opt-out, `verify_ssl` defaulting to true, at exact parity with the reference
  (signalwire/skills/mcp_gateway/skill.py:146 `self.params.get("verify_ssl",
  True)`). Not a divergence.
* SIGNALWIRE_REST_CA_FILE and SIGNALWIRE_RELAY_CA_FILE both work and are
  already covered live by tests/tls (13 runs, 0 skips).

ruby is clean on #90.
…th axes

Re-drift against porting-sdk oracle 0e0f935. SURFACE-DIFF was red with 11
findings; the cause is in the reference, not the port.

signalwire-python e9aa402 made `SkillBase.get_prompt_sections()` a FINAL template
method that applies the `skip_prompt` guard and delegates to a PROTECTED
`_get_prompt_sections()` hook (core/skill_base.py:89-96). 13 skills now override
the protected hook, so the oracle records the PUBLIC member on the BASE ONLY.
Ruby still overrides the public method on 12 skill classes, and a reflective dump
reports each override as new public surface — 10 phantom `missing-reference`
additions plus 1 omission gone dead.

An override of a base member is INHERITANCE, not new surface: the capability is
already published by the base the reference records it on. So both enumerators
now ask the LIVE oracle rather than trusting a projection: on a
`signalwire.skills.*.skill` class, drop a member the oracle records on `SkillBase`
but NOT on that subclass. Self-correcting in both directions — the oracle dropping
a member folds the port's override with no hand edit, and the oracle STARTING to
record it on a subclass stands the fold down, because the rule tests the subclass
and not just the base. `__init__` is exempt (construction shape is recorded per
class, never inherited).

MIRROR GAP, same shape as the `instance_key` alias last turn: the rule had to go
in BOTH enumerators. The two project the SAME Ruby skill classes onto the SAME
reference modules, so a projection rule on one side leaves the other reporting the
same method as un-folded.

The signature axis was green only by ACCIDENT and had to be fixed too:
`get_prompt_sections` is a zero-arg reader, so diff_port_signatures.py swept all
12 into `excused` under the `port-side state accessor (no Python counterpart)`
leniency — a rule about ports exposing state as properties, unrelated to a
template method. The divergence was real and simply invisible on that axis.

THE TWO ORACLES ARE NOT INTERCHANGEABLE. `python_signatures.json` deliberately
SKIPS a subclass override whose signature is identical to the base's, so asking IT
"does the subclass record this?" answers no for EVERY base-identical override.
Measured: gating the signature side on the signature oracle folded 33 members
instead of 12 — it would have deleted setup / register_tools / get_instance_key /
get_parameter_schema / cleanup / get_hints from ApiNinjasTrivia,
PlayBackgroundFile, Spider, WeatherApi and WikipediaSearch, all genuinely
implemented and genuinely declared. That is a capability deletion, not a fold.
Both sides now read `python_surface.json`, which records what each class DECLARES
— the question the fold actually asks. enumerate_signatures.py gains that oracle
as a second hard dependency, fail-loud on absence like the first.

DEAD ENTRIES DELETED (required, not optional) — zero entries added:
  PORT_OMISSIONS.md  NativeVectorSearchSkill.get_prompt_sections
                     (absent from python_surface.json; excused nothing. The other
                      NativeVectorSearch entries stay — `cleanup` and
                      `get_global_data` are still recorded, and the Python-only
                      ruling (§I.1) is untouched.)
  PORT_ADDITIONS.md  ClaudeSkillsSkill.get_prompt_sections
                     InfoGathererSkill.get_prompt_sections
                     (both went dead the moment the fold stopped emitting them;
                      the gate reports a dead addition as an error.)

Measured, --omissions + --surface-omissions + --surface-additions on both sides:
  surface  drift   11 -> 0   (10 additions + 1 dead omission)
  signature drift   0 -> 0
  excused (sig)  1099 -> 1087   SET delta: 0 OPENED, 12 CLOSED, every one a
                                `<Skill>.get_prompt_sections` missing-reference.
                                Absorbed nothing.
  PORT_OMISSIONS  -1, PORT_ADDITIONS -2, PORT_SIGNATURE_OMISSIONS unchanged.
  Both enumerators fold exactly 12 members, matching member for member.

Negative controls, both enumerators:
  - Perturb the oracle to record `get_prompt_sections` on JokeSkill again: both
    enumerators re-emit it on JokeSkill and ONLY JokeSkill (MathSkill and the
    other 10 stay folded). The gate is oracle-driven, not name-driven.
  - Empty the oracle's SkillBase member list: the fold stands down entirely and
    every skill class matches its pre-fold surface byte for byte — the fail-safe
    never emits a mass deletion when the oracle cannot be resolved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…ling.conference

porting-sdk be7a34f added two NET-NEW relay schemas,
`relay-protocol/calling.conference.{params,result}.json`, extracted after
mod_infrastructure 9755ef7 registered a second protocol method
(`swclt_sess_register_protocol_method(..., "conference", ...)`, relay.c:18915).
The extractor is unchanged — NEW SERVER SURFACE, not drift.

GEN-FRESH-RELAY was already green across that change and the emitted tree did not
move: both new files are PERMISSIVE PLACEHOLDERS (`type: object`,
`additionalProperties: true`, `x-permissive: true`, no `properties`), and the
generator's `is_object_schema` test already drops a property-less schema. That is
the parity-correct outcome rather than a miss — the reference records exactly this
kind of placeholder as a module-level `TypeAlias = dict[str, Any]`, which its own
enumerator drops, so emitting a Ruby data class for one would ADD surface the
reference does not publish. A port whose generator globs `*.{params,result}.json`
with NO permissive filter gains two open type aliases here; ruby's filter is why
it gained none.

Only the docstring was wrong, and it was wrong on both terms even before be7a34f:
it claimed "126 params/result files - 3 empty-object placeholders = 123". The
counts are now 128 - 5 = 123 (the emitted total was right; the inputs were not).
Enumerate the 5 dropped placeholders by name and say why the count is stable as
the server grows, so the next reader can re-derive it instead of trusting a
number:

  calling.call.{params,result}         x-permissive, additionalProperties
  calling.conference.{params,result}   x-permissive, additionalProperties  <- new
  signalwire.disconnect.result         empty `properties: {}`

Doc-only; no generated output changes. Verified: `--check` clean on all five
generators (RELAY, REST, REST-TESTS, SWAIG, SWML), and the emitted tree still
holds 123 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
GEN-FRESH-SWAIG was red on 6 generated files. Two spec re-vendors landed in
porting-sdk without any port being regenerated; the generators did not change.

  * 4336b98 (2026-08-01) re-vendored post-prompt.yaml. It moved
    PostPromptSystemLogEntry's `context` / `step` / `step_index` from top-level
    properties into `metadata.properties`, citing tl_stamp_location (timeline.c)
    as where the server actually stamps them -- so those three top-level
    accessors were never on the wire at that level, and they are dropped. The
    same re-vendor typed two PostPromptSwaigLogEntry fields off their call sites:
    `mcp_response` is the MCP tool's raw result text (actions.c:2158, "Not parsed
    JSON") so :object -> :string, and `mcp_error` is a boolean const true present
    only when the tool returned no result (actions.c:2162) so :string ->
    :boolean. Both committed types were wrong.

  * 99fd429 (2026-08-03) re-vendored swaig-response.yaml at mod_openai cac4984,
    which replaced the untyped `{}` property stubs with real types read off
    process_action's call sites: context_switch system_prompt/user_prompt ->
    :string, hold timeout integer -> ["number","string"] (:number), playback_bg
    file -> :string, transfer dest -> :string. Its extractor also emits each
    action object's property keys ALPHABETICALLY, hence ContextSwitchAction's
    field order.

Net effect on the surface is a type tightening plus the three dropped accessors;
DRIFT stays clean against the Python oracle. port_signatures.json drops the
three removed accessors to match. Ruby has no port_surface_native.json.

Verification:
  [GEN:GEN-FRESH-SWAIG] ... PASS   (suites/gen.py --port ruby, all 5 rules PASS)
  [SURFACE:DRIFT] ... PASS         (suites/surface.py --port ruby, all 7 PASS)
  run-tests.sh: 2762 runs, 7795 assertions, 0 failures, 0 errors, 0 skips
  run-format.sh --check: 1464 files inspected, no offenses detected

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i9TibArqEmDszG7VYEGYg
CI's Test workflow failed FMT and LINT at 66f5307 with 3
Layout/MultilineMethodCallIndentation offenses, while a local run of the
exact same commit reported "1464 files inspected, no offenses detected".

Cause: `gem 'rubocop', '>= 1.80'` is an open floor. CI installs fresh and
resolved 1.89.0; local used the committed Gemfile.lock at 1.88.0. 1.89.0
tightened Layout/MultilineMethodCallIndentation, so the gate could not be
reproduced locally at any effort — the two runs were not running the same
linter. Confirmed by upgrading local to 1.89.0, which reproduced all 3
offenses at the identical files and lines.

Fixes both halves:
  - autocorrect the 3 offenses (whitespace-only; no semantic change)
  - bound rubocop/-minitest/-performance on the minor version, so a new
    release cannot turn CI red with no code change

Verified: FMT and LINT both report 0 offenses at CI's own 1.89.0, and
`bundle update rubocop` (which previously moved 1.88 -> 1.89) now holds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i9TibArqEmDszG7VYEGYg
ruby was the last floating ruff in the fleet. Four workflow steps installed it
with no constraint at all:

  .github/workflows/test.yml:70      pip install ruff -> ruff==0.15.21
  .github/workflows/nightly.yml:127  pip install ruff -> ruff==0.15.21
  .github/workflows/nightly.yml:212  pip install ruff -> ruff==0.15.21
  .github/workflows/publish.yml:56   pip install ruff -> ruff==0.15.21

An unbounded install resolves the newest release at run time, so PY-LINT and
PY-FMT's verdict was a function of WHEN the runner was provisioned rather than of
the source: CI installs fresh while a contributor runs whatever they installed
months ago, ruff adds rules and adjusts format heuristics between releases, and
the resulting CI red does not reproduce locally because the difference is not in
the code.

This is not hypothetical here — this repo already paid for the identical shape
through its OTHER linter one commit ago (2142491): `gem 'rubocop', '>= 1.80'` let
CI resolve 1.89.0 against a local 1.88.0, 1.89 tightened
Layout/MultilineMethodCallIndentation, and CI failed FMT+LINT with 3 offenses on
a commit whose local run reported "1464 files inspected, no offenses detected".
The rubocop half was bounded then; ruff was left open.

0.15.21 is the fleet-wide version (signalwire-python/-perl/-php already declared
it; the rest of the matrix was pinned to it the same day).

A manifest/workflow pin alone cannot make local == CI, so the pin is also
ASSERTED at gate time. pip does not re-resolve an already-satisfied requirement,
so an environment provisioned before a pin is tightened keeps its old ruff
indefinitely — the pin is then right in the workflow and violated in the
interpreter that actually runs the gates, invisibly. scripts/_env.sh gains
SW_RUFF_VERSION + _sw_assert_ruff_version(), called from sw_ruff so both PY-LINT
and PY-FMT route through it; the workflows additionally assert the resolved
version right after installing (an install that silently did not take is worse
than no pin). SW_ALLOW_TOOL_VERSION_DRIFT=1 downgrades the mismatch to a warning,
for a deliberate bump-and-fix run only, matching the escape hatch signalwire-go
and signalwire-perl use.

The assertion compares the extracted x.y.z rather than the raw string, so the
`python3 -m ruff` and bare-binary spellings of the same version compare equal —
signalwire-go hit exactly that trap with actionlint's v-prefix and reinstalled on
every invocation.

Pinning surfaced ZERO new violations: the box already had ruff 0.15.21, so
0.15.21 is the version the tree was already clean under, and PY-LINT/PY-FMT were
green before and after. (Correction to the brief that scoped this work: the
shared venv here was NOT on 0.14.2 — `python3 -m ruff --version` reports 0.15.21
for /Users/michaeljerris/src/signalwire-agents/venv/bin/python3, the interpreter
_env.sh resolves. The real hole in ruby was the missing ASSERTION, not a drifted
binary.)

Verified via run-ci (identical gate set before and after, 25 gates):
  [PY-LINT] run-py-lint.sh (ruff check, zero findings over scripts/*.py) ... PASS
  [PY-FMT] run-py-format.sh (ruff format over scripts/*.py) ... PASS
  [LINT] run-lint.sh (rubocop zero offenses, whole repo minus vendor/) ... PASS
  [FMT] run-format.sh (whole repo minus vendor/) ... PASS
Negative control, driven through the real gate entry points by rewriting the
DECLARED pin (SW_RUFF_VERSION is assigned unconditionally — a declared pin must
not be env-overridable or the pin is bypassable): at 9.9.9, run-py-lint.sh and
run-py-format.sh --check each exit 1 ("ruff is '0.15.21', not the pinned 9.9.9"),
CI=1 exits 1 with the workflow-flavoured hint, and
SW_ALLOW_TOOL_VERSION_DRIFT=1 warns and exits 0; at the real pin both exit 0.

Not from this change: run-ci reports CI FAIL (gates: SURFACE) on this branch —
DRIFT/SURFACE-FRESH/SURFACE-DIFF surface-parity debt, identical in the
pre-work baseline taken before the first edit. A workflows/scripts-only diff
cannot reach the port surface. Also pre-existing and unrelated: actionlint's
SC2209 on test.yml's `tier=pr` step (my inserted comments moved it from line 85
to 100; ACTIONLINT is deliberately not wired into ruby's run-ci).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i9TibArqEmDszG7VYEGYg
Follow-up to 76ff2f2, which left one install hint unpinned. When ruff is absent
entirely, _sw_ruff_cmd fails loud and tells you how to install it — but it said
`pip install ruff` (or `brew install ruff`, which has no version selection at
all). Both land whatever is newest, which _sw_assert_ruff_version then rejects,
sending someone round the loop twice: install, fail the assertion, read the
pinned version out of the error, reinstall. The hint now names the pin directly.

Verified on the real not-found path (env -i with ruff off PATH):
  FATAL: ruff not found (needed to lint/format the Python under scripts/).
         Install it with: python3 -m pip install ruff==0.15.21
and the gates are unaffected: run-py-lint.sh exit 0 ("All checks passed!"),
run-py-format.sh --check exit 0 ("7 files already formatted").

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i9TibArqEmDszG7VYEGYg
…e $ref names

PR #75 was `==> CI FAIL (gates: SURFACE )`, three rules red — DRIFT,
SURFACE-FRESH and SURFACE-DIFF — with no ruby commit causing it. Cause is
porting-sdk's coordinated pin: PORTING_SDK_REF is the MUTABLE branch
wave6/ctor-dunder-fold, whose d8e5787 re-vendored the six stale server specs
and 4ddda70 taught the reference generator to resolve cross-file $refs. Its
own message says the re-vendor "stales GEN-FRESH-SWAIG in typescript, go,
ruby, php and dotnet; that regen ... stays a separate, explicit act." This is
that act for ruby.

66f5307 already did the CODE half (GEN-FRESH-SWAIG is green), but it
regenerated only what the generator KNEW how to emit, and the generator was
short two classes.

1. THE ENVELOPE. post-prompt.yaml's `post_response` / `delayed_post_response`
   now resolve `swaig-response.yaml#/components/schemas/SwaigResponse` instead
   of degrading to a dict, so the reference records two new classes in the
   swaig_actions_generated module. `_build_swaig_actions` emitted only the
   per-verb `<Verb>Action` value objects lifted out of `SwaigAction.properties`
   and never the SwaigAction / SwaigResponse envelopes those refs point at.
   That read to the audit as:

     DRIFT        5 drifts — gen-payload.SwaigAction.{context_switch,hold,
                  playback_bg,transfer} and gen-payload.SwaigResponse.action
     SURFACE-DIFF 2 Python symbols missing from port —
                  signalwire.core.swaig_actions_generated.{SwaigAction,SwaigResponse}

   Both envelopes are now emitted from swaig-response.yaml's OWN schemas,
   mirroring the reference's generate_swaig_actions() trailing emit. They are
   emitted with `emit_readers=True`, unlike the per-verb value objects: the
   envelopes carry class-typed fields (SwaigAction's four action keys name the
   lifted `<Verb>Action` types; SwaigResponse.action names SwaigAction), so
   griffe DOES record them in the reference SIGNATURE oracle, whereas a
   `<Verb>Action` has no class-typed field and is dropped from it on both
   sides. The extra readers for the scalar keys the reference's class-typed
   filter drops are excused as port-side state accessors (zero-arg, `any`
   return) — the same leniency PostPromptSwaigLogEntry already relies on.

   The SURFACE oracle records both envelopes METHOD-LESS, and
   enumerate_surface.rb's ORACLE_FIELD_ACCESSOR_MODULES deliberately does not
   list swaig_actions_generated, so they surface method-less with no enumerator
   change.

2. THE STALE COMMITTED SURFACE. SURFACE-FRESH compares a fresh regen against
   `git show HEAD:port_surface.json`, and the committed file recorded
   PostPromptSwaigLogEntry with ONE member (`post_data`) where a fresh
   enumeration finds three — `delayed_post_response` and `post_response` became
   class-typed by the same cross-file $ref resolution, so the oracle now records
   them. 66f5307 regenerated the ruby source but not the surface artifact.

   NOTE THE REGEN ORDER: enumerate_signatures FIRST, then enumerate_surface.
   The surface enumerator imports composition members by READING
   port_signatures.json off disk, so the reverse order silently produces a
   port_surface.json missing the new leaves while both commands exit 0.

3. context_switch_action.rb: `system_pom` / `user_pom` :any -> :object. Not a
   hand edit — 4fe26bb's spec types both as `type: object` (x-key-source
   app_config.c:1106), where they were previously untyped. Same delta go
   recorded in a0bba17.

No hand edits to generated output; every artifact here is the verbatim output
of the generators. No gate weakened, no omission or allowlist entry added.

Verification (porting-sdk pinned at wave6/ctor-dunder-fold @ 4fe26bb):

  python3 scripts/generate_swaig_payloads.py -> exit 0
    "generated 22 SWAIG-payload file(s)"  (was 20)
  python3 scripts/enumerate_signatures.py -> exit 0
    "(106 modules, 2529 methods, 35 functions)"  (was 105 / 2499)
  suites/surface.py --port ruby -> DRIFT PASS, SURFACE-DIFF PASS
    "port matches Python reference (2639 symbols; 20 excused omissions,
     448 excused additions)"
  suites/gen.py --port ruby -> exit 0, "[GEN] all 5 rules PASS"
  scripts/run-tests.sh -> exit 0
    "2762 runs, 7790 assertions, 0 failures, 0 errors, 0 skips"
  scripts/run-format.sh / run-lint.sh -> "1466 files inspected, no offenses"
  scripts/run-py-lint.sh / run-py-format.sh -> exit 0

Negative control: with swaig_action.rb + swaig_response.rb deleted and the
oracles re-enumerated, surface.py reproduces the ORIGINAL failure exactly —
"[SURFACE] FAILED rules: DRIFT SURFACE-FRESH SURFACE-DIFF", 5 signature drifts,
2 missing Python symbols. Restored, and the gate goes green again.

FLEET NOTE: go landed the identical envelope fix in 41a012c + regen a0bba17.
typescript, php and dotnet carry the same coordinated-pin red and each still
owes its own explicit regen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
Repoints ruby's RELAY-protocol generator off the legacy
`porting-sdk/relay-protocol/` directory -- one standalone JSON-Schema file per
method+phase -- onto the single document `porting-sdk/combined-specs/relay.yaml`,
which now carries both halves (`methods.<name>.request.params_dto` and
`.response.result`), following php (the R11 proof port).

build_outputs() loses its glob / `.params.json`-vs-`.result.json` suffix split /
`x-method`-with-filename-fallback / dedupe-by-filename block and iterates the
mapping the shared reader serves:

    RPS.shapes(psdk, phase) -> {method: schema_node}

`porting-sdk/scripts/relay_protocol_shapes.py`, loaded by file path exactly as
this script already loads generate_rest.py, since porting-sdk is a sibling
checkout and not an installed package. The method name now comes from the
document's own key rather than an `x-method` field, and the phase from the block
the shape was carried in rather than a filename suffix. The naming and emit
policy are untouched.

Output is unchanged, per phase, at an exact bound:

  params  62 classes -> 62 (0)   318 properties -> 318 (0)
  result  61 classes -> 61 (0)   280 properties -> 280 (0)
  total  123 classes -> 123 (0)  598 properties -> 598 (0)

All 123 emitted files are byte-identical with NO provenance exception: ruby's
emitted header names the producing script, not the input directory, so unlike
php/perl there is no provenance line to hold constant. GEN-FRESH is green and
was negative-controlled (appending a line to one generated file reports exactly
that file stale).

The docstring's claim that ruby carried a special "permissive filter" other
ports lacked did not survive the source -- ruby uses the same shared
`is_object_schema`, and the two property-less placeholders are dropped by its
`len(properties) > 0` arm, which every port shares. Restated accordingly.

The combined document omits the `type: object` the per-file envelope declared;
`is_object_schema`'s `(type is None and properties)` branch covers it, so the
object-vs-alias verdict is unchanged and the surface stays at the oracle's 123.

`relay-protocol/` is not deleted; other generators still read it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
The re-vendor changed the spec these ports generate from and they were never
regenerated, so GEN-FRESH-SWAIG went red across seven ports and SPEC-FANOUT
reported them in aggregate on porting-sdk #125.

Purely additive, as a legitimate re-vendor should be: SwaigAction gains the
SWML action; SwaigRequest gains SWMLCall and SWMLVars. No existing value
changes. The new SWML action is the same one SWAIG-COVERAGE reported the SDK
could not emit, so this closes that gate too.
…oad regen

The payload regen updated the generated SWAIG files but not the signature
artifact that describes them, so SIGNATURES-FRESH went red on every port that
carried it -- "committed_signatures.json does NOT match a fresh regen".

Additive only: the new members are the SWML action and the SWMLCall/SWMLVars
request fields the payload regen introduced.
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