wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold) - #75
Open
mjerris wants to merge 86 commits into
Open
wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold)#75mjerris wants to merge 86 commits into
mjerris wants to merge 86 commits into
Conversation
…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>
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 referencepublishes a
constructionentry 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.mdandPORT_ADDITIONS.mdare untouched (they belongto a different tool with a hard dead-entry gate). No allowlist/omission/divergence entry
added.
Before / after counts
PORT_SIGNATURE_OMISSIONS.md(parsed entries)PORT_OMISSIONS.mdPORT_ADDITIONS.mdLine-level:
1 file changed, 8 insertions(+), 37 deletions(-). The 30 entries plus thenow-empty
# Ruby **base spreadsection header; the 8 insertions replace headercategory 4 (which documented the
**baseevent-spread idiom) with a note stating thefold, so the next reader does not re-add what the diff now folds.
What was deleted:
signalwire.relay.event.*Event.__init__base-spread entries — the entire"Ruby
**basespread" section, now empty and removedprefabs.receptionist.ReceptionistAgent.__init__,prefabs.survey.SurveyAgent.__init__(
**_optsforwarding)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 notexcused, it is not compared at all:
66d351a^)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.
Construction node UNCHANGED
__init__-as-a-member and §10 construction params are different contracts; the fold mustnot trade a ledger entry for a blind spot.
port_signatures.json's top-levelconstructionkey, HEAD vs. after a fresh regen:A fresh
python3 scripts/enumerate_signatures.py --out port_signatures.json(ruby'senumerator takes
--out) leaves the tree clean — mtime moved,git diffempty — so thecommitted artifact was already current and nothing needed re-committing.
__init__entries the rule does NOT cover — left LIVE3 entries stay, deliberately untouched. Their classes have no
constructionentry in thereference, so the member comparison is the only comparison there is:
(
False=cls_path in reference["construction"].) All three arereference-oracle gapentries: the reference surface records the skill class but the signature oracle omits
its
__init__. Ruby has zero non-__init__dunder entries, so the fold'soutright-exclusion half retires nothing here.
Gate output
Signature diff, all three surface flags (post-fold diff + pruned ledger):
Full
bash scripts/run-ci.sh— exit 0, every gate PASS:SURFACE(which contains SURFACE-DIFF, the gate that reds ifPORT_OMISSIONS.mdispruned 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.
Coordinated-With: porting-sdk@wave6/ctor-dunder-fold