wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold) - #55
Open
mjerris wants to merge 87 commits into
Open
wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold)#55mjerris wants to merge 87 commits into
mjerris wants to merge 87 commits into
Conversation
`signalwire::core::logging_config::get_logger(name)` — the entry point the
reference oracle records as `signalwire.core.logging_config.get_logger`, and
which the 2026-07-24 logger ruling makes MANDATORY surface — returned `bool`
(the internal configured-once flag) and discarded `name` entirely:
bool get_logger(const std::string& /*name*/) {
if (!g_configured.load()) configure_logging();
return g_configured.load();
}
So a caller could not obtain a logger from the canonical entry point at all.
They had to already know to reach for a DIFFERENT header. Every other port
returns a logger object here (ts Logger, go *logging.Logger, java/php/rust/
dotnet Logger, ruby Logging::Logger), so `bool` was a functional gap, not a C++
idiom.
Now returns `signalwire::logging::Logger` by delegating to the existing
`signalwire::logging::get_logger(name)`, which already built the named-logger
form. This entry point adds only the configure-first guarantee — exactly the
reference's single-entry-point contract. Zero callers depended on the bool
(grepped), so nothing breaks.
WHY IT WENT UNNOTICED: the reference records this function's return as `any`
(structlog's BoundLogger is not an SDK class), and diff_port_signatures.py:153
returns True when EITHER side is `any`. So `bool` compared clean. The gate is
blind here by construction — the concrete cost of an `any` return.
Note C++ has THREE get_logger overloads across two namespaces, with two
different Logger classes and two different LogLevel enums (`Debug`/`Info`/… vs
`DEBUG`/`INFO`/…): `signalwire::get_logger()` → process singleton by reference;
`signalwire::logging::get_logger(name)` → named Logger by value; and this one,
the oracle contract point, which now delegates to the named form. Easy to
conflate — I did, first wiring this to the singleton.
TESTS: 10/10 in the logging suite, and the output proves the contract holds —
`[ERROR][ContractCheck] contract smoke` shows a named, usable logger where the
old signature could only yield a bool. The regression guard is a static_assert
on the return type: it fails to COMPILE if this ever reverts to bool, which
needs no global state to check.
Also fixed `logging_named_get_logger`, which was `auto logger = ...` plus a
"should not crash" comment and NO assertion — it would have passed with the name
dropped entirely.
Deliberately NOT asserting on captured log output: `logging::Logger` streams to
std::cerr with no injection point and keeps name_ private, so observing it means
swapping the PROCESS-WIDE cerr buffer — and test_main.cpp runs tests on multiple
threads, so that steals concurrent tests' output including their ASSERT text.
RULES.md §4: isolation comes from scoping, never from mutating shared state.
PRE-EXISTING, NOT FROM THIS CHANGE: cpp's signature gate exits 1 with 192 drifts
(AgentServer.enable_sip_routing, ToolDecorator.*, …) on clean main as well — both
from the committed artifact and from a fresh regen of an unmodified tree. This
commit's regen moves exactly ONE line, `"returns": "class:signalwire.logging.
logger.Logger"`, and adds zero drifts. The 192 are a separate stale-artifact
backlog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
…r.hpp
`include/signalwire/logging/logger.hpp` had been ORPHANED from the lint graph:
nothing in the linted set included it. The previous commit adds
`#include "signalwire/logging/logger.hpp"` to `core/logging_config.hpp`, which
pulled it in and exposed 20 pre-existing violations. So they are pre-existing
code, but MY change is why they now fail the gate — LINT went red on this branch,
and leaving it red is not an option.
Fixed all 20, no suppressions and no allowlist entry:
- performance-avoid-endl x4: `<< std::endl` -> `<< "\n"`. Behaviour preserved:
endl also flushes, but std::cerr is unit-buffered, so each write already
flushes regardless.
- readability-braces-around-statements x18 (10 sites): braced every
single-statement `if` in get_log_level() and the four log methods.
- readability-redundant-string-init x2: `std::string level = "";` -> declare.
VERIFIED: run-lint.sh exit 0 (was 20 errors), run-format.sh --check exit 0,
run-tests.sh logging 10/10.
ALSO RETRACTING A FALSE ALARM FROM THE PREVIOUS COMMIT MESSAGE. It claimed cpp's
signature gate "exits 1 with 192 drifts on clean main". That was MY invocation
error, not a real red:
with --surface-omissions + --surface-additions: exit 0
without them (what I ran): exit 1
CLAUDE.md §5b states the bar explicitly — "Exit code 0 from
diff_port_signatures.py (with all 3 surface flags)". I omitted two of the three
and read the resulting phantom as a backlog. The real gate agrees:
`run-ci.sh --rules SIGNATURES,DRIFT` => `==> CI PASS`.
The 192 phantoms were dominated by the known REST-shape family (25 on
RestClient) — and cpp is not even an outlier there: every port emits the same 6
Namespace classes while flattening a varying number of accessors onto RestClient
(rust 32, php 28, cpp 27, dotnet 6, ruby 6, java 5, go 1, ts 1). That is task
#38's crud_bases/REST-shape item, and the surface ledgers already account for it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GFKJhLvfV8yGrASwqxdgaf
…ff fold)
The shared-diff ctor/dunder fold (_is_folded_dunder_member, porting-sdk #125)
excludes __init__-as-a-member findings while the class is in the reference's
construction node, so the ledger entries that excused them can no longer be
reached. Remove the 59 dead entries.
PORT_SIGNATURE_OMISSIONS.md entries: 331 -> 272 (-59)
All 59 are <class>.__init__ where <class> is in the oracle's construction node;
cpp had no non-__init__ dunder entries. Also drops three rationale-tag
definitions now cited by zero entries (cpp_constructor_default_only,
cpp_questions_string, cpp_rest_error_field_layout) and the emptied
"### __init__ default-only / config-struct construction" header.
Three __init__ entries are NOT covered and stay:
signalwire.rest._base.{CrudResource,CrudWithAddresses,ReadResource}.__init__ --
absent from the construction node, and the C++ port emits an __init__ the
reference does not record, so each is a real extra-port finding. Stripping them
reds the already-folded differ (exit=1, 3 drifts).
Excused divergences: the FOLD moves them 1118 -> 1059 (measured with the
pre-fold differ at 66d351a^); the PRUNE leaves them flat at 1059, because the
fold continues before the excusal branch. The section-10 construction contract
is untouched: port construction classes 146 -> 146.
PORT_OMISSIONS.md and PORT_ADDITIONS.md are deliberately untouched -- different
tool, hard dead-entry gate.
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
The signature oracle now records DERIVED public __init__ attributes that are
caller-observable VALUES (porting-sdk d7c859d + 387667e, ALLOWLIST_DISCIPLINE
class B2). Six of the seven landed on cpp as drift; this closes all of them.
Already implemented, needed no port change:
SignalWireRestError.request_id — HttpClient already extracts it from the
response headers and exposes request_id().
Folded at the enumerator (the accessor existed; the projection did not list it):
Action.completed — the unified C++ relay::Action already has completed()
(is_done() delegates to it). Added to the base-Action
projection in BOTH enumerators, alongside control_id.
Implemented:
SWMLService.ssl_enabled / .ssl_cert_path / .ssl_key_path / .domain
The reference copies these four off self.security in __init__ and lets
run() override them. cpp resolved TLS straight from the environment at
serve() time and stored nothing, so the values were not observable at
all. Now the ctor builds a SecurityConfig from config_file + the service
name and seeds the four fields from it (the reference's exact wiring), an
accessor+setter pair reads/overrides each, and BOTH serve() paths
(swml::Service and AgentBase) drive TLS off those fields instead of
re-reading the env. SWML_SSL_* still applies — SecurityConfig reads it —
but an explicit set_ssl_*() now wins, matching run(ssl_enabled=…).
SpiderSkill.remove_xpaths
The reference PREFILLS seven selectors in __init__ and drop_tree()s each
match before extracting text. cpp had no equivalent: its naive tag-strip
replaced tags with a space, so <script> source and <style> CSS leaked
into the "scraped content" it handed the LLM. Now a prefilled field with
a reader/setter drives an element-drop pass (tag AND body) ahead of the
strip.
Defect found while wiring the above: "spider" is registered TWICE — by
src/skills/builtin/spider.cpp AND by SpiderSkillR in src/skills/skill_registry.cpp.
SkillRegistry::register_skill overwrites, so which implementation a caller gets
is static-init order; SpiderSkillR is the one that currently wins (proved via
skill_description() == "Web scraping"). remove_xpaths is implemented on BOTH so
the surface is honest either way, and the signature projection requires the
accessor in BOTH sources. The duplicate itself is pre-existing and is NOT
resolved here — it needs its own change.
Tests: 3 new SWMLService TLS cases (default-off, seeded-from-SecurityConfig,
settable-after-construction) and 1 new spider case proving every default
selector's content is dropped while the real body survives. Action.completed
was already covered by test_relay_action.cpp.
DRIFT 6 -> 0; run_tests 2043/2043.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Two curated-set errors from the previous commit, both in the spider xpath-drop pass: performance-inefficient-string-concatenation — the element-drop regex was assembled with chained operator+ on std::string, allocating a temporary per link. Build it with reserve() + append(). bugprone-exception-escape — the scrape/crawl tool handlers captured the xpath list BY VALUE, so constructing the lambda's closure could throw (vector copy allocates) inside a handler the checker requires to be nothrow. Capture a shared_ptr<const vector<string>> instead: the copy is nothrow, the list is still shared by value rather than through `this` (a ToolDefinition outlives the skill instance that registered it, so a `this` capture would dangle), and it is made once at registration. LINT exit 0 (0 errors); run_tests 2043/2043. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… were the degraded ones Filed as "spider is registered twice". It was every built-in skill. Each src/skills/builtin/<name>.cpp class had a twin (<Name>SkillR) in skill_registry.cpp claiming the same name. register_skill OVERWROTE silently, so the registry copy won EVERY time — while both surface enumerators read only builtin/. The port's entire enumerated built-in-skill surface was being measured against code that never ran. Parity could pass against dead code. `datetime` was worse: both classes were literally signalwire::skills::DateTimeSkill in two translation units — an ODR violation. The linker merged them and the BUILTIN won, which is why datetime resolved the OPPOSITE way from spider in the same binary. THE LIVE COPIES WERE THE BROKEN ONES. swml_transfer had no expressions so no transfer ever fired; play_background_file did nothing; info_gatherer never advanced its state machine; datasphere_serverless sent no auth; custom_skills gave every tool an empty schema; datetime ignored the timezone argument. TWO REAL DEFECTS UNDERNEATH: 1. WIRE BUG (datasphere) — the one case where the twin was right. The reference POSTs the collection path with document_id in the BODY, sends `distance`, and parses `chunks[]`, commenting "DataSphere API returns 'chunks', not 'results'" (signalwire-python skills/datasphere/skill.py:244-245 — verified directly). The builtin used a per-document path and parsed `results[]`, so it returned EMPTY against the real upstream. Correct shape ported across. 2. INVENTED SURFACE (swml_transfer) — making the builtin live turned SKILL-CONTRACT red: it emitted an `enum` the reference does not have. Removed, per "invented -> delete it". SKILL-CONTRACT 12+1-diff -> 13/13 matching. MADE UNREPRESENTABLE: register_skill now THROWS on a duplicate name (a second registration of an existing name is always a bug). Re-introducing the original defect now SIGABRTs at static init naming `spider`, rather than silently picking a winner. PROVED NO SKILL WAS LOST, mechanically rather than by inspection: rebuilt the pre-change tree from stash and enumerated the LIVE registry on both builds — 18 before, 18 after, `diff` exit 0. 16 of 18 now report the builtin's reference-matching description. All three mutations RED: guard removed -> guard test fails; remove_xpaths emptied -> both strip tests fail; original defect re-introduced -> SIGABRT. Tests 2046/2046. FMT/LINT/DRIFT exit 0. SURFACE all 8 rules pass. port_signatures.json byte-identical (derived-attr work intact). No omission/addition/allow-list entry. BEHAVIOUR CHANGE worth a release note: 16 skills change behaviour, all TOWARD the reference, but three go from non-functional to functional. Pre-existing and untouched: BEHAVIORAL:SECURE-DEFAULT (the legacy self-classifying dump in tools/secure_default_dump.cpp, upstream gate redesign). Refs #91
…eb_hook_url
TWO defects, shipped together because either alone leaves the port broken.
1. THE BRIEFED ONE. AgentBase::build_swaig_functions (agent_base.cpp:1489) passed the
local webhook URL to to_swaig_json(url) UNCONDITIONALLY, so an insecure tool got its
own tokenless web_hook_url — an unauthenticated function-specific callback. C++ has no
per-tool external webhook, so the reference's three-way branch reduces to the elif:
const bool wants_own_webhook = !token.empty() || !swaig_query_params_.empty();
2. cpp emitted SWAIG.defaults ONLY when the constructor's default_webhook_url_ was set
(pre-fix agent_base.cpp:1616-1622 was the sole emitter). Verified with a live render
probe BEFORE fixing: the guard alone left the insecure tool with no reachable callback.
Now emitted whenever functions exist per agent_base.py:1108-1113, with an explicit
default_webhook_url still winning.
cpp is the SIXTH and final affected port, and all six had BOTH defects — the defaults gap
was universal, not incidental. The gate structurally cannot see defect 2 (php proved it
stays GREEN with the block removed), so every one would have shipped green with
unreachable insecure tools.
VERIFIED INDEPENDENTLY (orchestrator): diff_port_secure_default.py --port cpp -> EXIT 0,
"✓ PASS — cpp".
Lane verification: full suite 2048/2048 exit 0. Mutation 1 (guard removed) -> 3 tests fail,
exit 1. Mutation 2 (defaults removed) -> 1 test fails, exit 1. Both restored byte-identical
(diff exit 0) and re-run green. drift.sh exit 0 (1557 ref / 1953 port symbols); SURFACE
PASS; FMT and LINT exit 0. Existing tests that asserted the tokenless key was PRESENT
encoded the defect and were corrected; 4 URL-composition tests retargeted by rendering with
a call_id — intent preserved, none weakened.
FOUND, NOT CHASED: core::SWAIGFunction::to_swaig is DEAD CODE carrying the same
unconditional-webhook shape — the same defect if it is ever wired. And cpp conflates the
agent-level webhook override (reference: defaults-only) into the per-tool entries;
pre-existing and separate.
Not in this commit: CMakeLists.txt, include/signalwire/security/session_manager.hpp,
src/security/session_manager.cpp, scripts/run-ci.sh and untracked tools/token_interop_mint.cpp
are a concurrent TOKEN-INTEROP lane's work in this shared checkout.
Refs #95
…mit made illegal MY REGRESSION, caught by the webhook-cpp lane, which correctly refused to absorb it into its own work. f0b5df5 (mine, earlier today) made SkillRegistry::register_skill THROW on a duplicate name — deliberately, because silent overwriting is exactly what let two different classes both claim "spider" and hid which one was actually live. But tools/state_dump.cpp:192 was re-registering "custom_alpha" a second time to assert that registration was IDEMPOTENT, with a comment saying "a duplicate name is a no-op". That is no longer true, so state_dump aborted (exit 134), the differ saw empty stdout, and BEHAVIORAL-STATE went red. The lane proved it was pre-existing relative to its own change by stashing all 5 of its files and rebuilding at the untouched tip — identical abort. FIXED BY DELETING THE ASSERTION, not by weakening the guard. The duplicate call bought nothing: the delta computed immediately below (names added over the pre-existing set) already proves the registry does not grow spuriously. The stale comment is replaced with the reason it went away, so nobody re-adds it. VERIFIED: state_dump exit 0 emitting real JSON (was exit 134, empty); diff_port_state.py --port cpp -> EXIT 0, "✓ PASS — cpp". Kept separate from the webhook security commit (3f8fe97) because it is a different defect with a different cause — mine, not the port's.
…aders
The enumerator recorded `"default": null` for every one of the 810 parameters it
knew had a default — it detected only that an `=` existed, never what followed.
The planned defaults-comparison gate would therefore have been VACUOUS for cpp:
passing on silence, the same failure mode that let a 4x-too-long SWAIG token
replay window (token_expiry_secs 3600 vs the reference's 900) sit unnoticed in
four ports until a human happened to look.
C++ declares real default arguments, so the value is right there in the header.
libclang's Python binding has no clang_getParmDeclDefaultArgument, but the
PARM_DECL cursor's token extent covers `<type> <name> = <expr>`, so the default
expression is recoverable as the tokens after the parameter's top-level `=`.
_parse_cpp_literal reduces that token list to a JSON-comparable value: integers,
floats, bools, string literals (including `""`), `nullptr`/`std::nullopt` -> null,
and `= {}` resolved against the parameter's canonical type. A default that is NOT
a static literal — an enum value (`Color::Red`), a constructor call
(`std::string("x")`), an arithmetic expression (`60 * 60`), a named constant
(`kDefaultMaxFileSize`) — is deliberately NOT evaluated and stays null. A guessed
value would be worse than a missing one: it makes a correct port look defective,
and "fixing" the port to match an invented default is how an insecure default
gets introduced for real.
Depth for the top-level `=` is counted PER CHARACTER, not per token: libclang
emits a nested template close as the SINGLE token `>>`. Decrementing once per
token left depth permanently positive for every
`std::optional<std::vector<std::string>>` parameter, so its `=` was never seen
and a real default read as NO default — flipping `required` false -> true on 32
params. Caught by an additive-only differ that asserts every field except
`default` is byte-identical before and after.
Comparable-to-reference params with a recovered default: 5 -> 128.
Reference-has-a-default-but-port-records-null: 131 -> 8.
The 8 remaining are honest and each explained: 5 are on record_call/tap, whose
PREFER_TYPED_OVERLOAD-selected enum overload genuinely declares those params
required; max_file_size is the named constant kDefaultMaxFileSize (the reference
does not record a value for it either, storing the string '100 * 1024 * 1024');
add_subsection's `numbered` is `std::nullopt` against the reference's False; and
replace_in_history's `text` has no default in C++ at all.
Recovering the values immediately surfaced 9 real port-vs-reference divergences
the null-filled output had been hiding. They are divergences in the C++ SOURCE,
verified by reading the headers, not enumerator errors — reported here, not
"fixed" by tampering with the enumerator:
FunctionResult::pay postal_code = "true" (string) ref True (bool)
SWMLService/WebMixin path = "/" ref "/sip"
InfoGathererAgent route = "/" ref "/info_gatherer"
SurveyAgent route = "/" ref "/survey"
ReceptionistAgent route = "/" ref "/receptionist"
FAQBotAgent route = "/" ref "/faq"
ConciergeAgent route = "/" ref "/concierge"
The 9th, `RestClient(*args)`, is not a defect: the reference records the Python
repr sentinel '()' for the oracle's sole var_positional param, against the port's
[]. That is a comparison-form question for the future gate to fold, not a value
error.
Additive by construction: parameter set, order, names, types, kinds, `required`
flags and return types are byte-identical to the previous artifact; only the
`default` field moved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…lted cpp agent answered the wrong URL These were invisible until cpp's enumerator started emitting real default VALUES (3376969); while every default read as null they compared equal. BEHAVIOURAL — five prefabs served the WRONG route. Each prefab's `route` defaulted to "/" instead of its own reference path, so a defaulted cpp agent answered a different URL than every other port: SurveyAgent "/" -> "/survey" prefabs/survey.py:64 ConciergeAgent "/" -> "/concierge" prefabs/concierge.py:54 FAQBotAgent "/" -> "/faq" prefabs/faq_bot.py:54 ReceptionistAgent "/" -> "/receptionist" prefabs/receptionist.py:42 InfoGathererAgent "/" -> "/info_gatherer" prefabs/info_gatherer.py:45 BEHAVIOURAL — SIP routing registered at the wrong path. Both `register_routing_callback(path)` defaults were "/" instead of "/sip" (core/mixins/web_mixin.py:1284, core/swml_service.py:921), so a defaulted callback was registered where the base route already sits and a POST to /sip 404'd. TYPE FOLD — `pay(postal_code)` was `std::string` only, excused by the omission `cpp_postal_code_string`. It is now `std::variant<bool, std::string>` with the reference's default `true`, matching the oracle's `union<bool,string>` exactly. The emission mirrors the reference's `isinstance(postal_code, bool)` branch: a bool becomes the lowercase string "true"/"false", a string passes through. The omission entry documented behaviour the code did not have — it claimed "the empty string acts as the sentinel" while the source read `= "true"`, so the empty string emitted an empty postal_code rather than any sentinel. The fold makes the entry unnecessary; it is REMOVED, not corrected. Measured, contrary to the brief that prompted this: the reference emits postal_code as a JSON STRING, not a boolean — `str(postal_code).lower()` at core/function_result.py:881, verified by running the reference. cpp's wire value was already correct; only the accepted parameter TYPE diverged. tests/test_default_fold.cpp asserts all of this behaviourally, never by construction. The prefab routes are proven by SERVING each default-constructed agent and probing all six candidate paths — the expected one must answer 200 with a rendered SWML document and the other five must not, so a stored-but-unmounted route fails. The routing-callback default is proven by a POST to /sip producing the callback's real 307 + Location. pay(postal_code) is asserted on the rendered wire payload, pinning `is_string()` alongside the value so a regression to a raw JSON boolean fails. Each change was mutation-tested: reverting a prefab route to "/" turns survey's served-route + full-URL assertions RED (9/11); reverting both routing-callback paths turns both /sip assertions RED (9/11); emitting a raw JSON boolean from pay turns both bool-arm assertions RED (9/11). Restored, 11/11 pass. port_signatures.json regenerated: the 8 fold deltas land, and postal_code now reads `union<bool,string>` / `true`. Against porting-sdk's COMMITTED diff_port_signatures.py, DRIFT exits 0 with the omission entry removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ead as drift for spelling "absent" differently
The unified drift checker now compares `default` and `required` on ALL params,
and C++ failed 78 of them for a reason that is not a behaviour difference:
Python spells "the caller omitted this" as `x: T | None = None` + `if x is not
None:`, and C++ — which has no nullable scalar and no keyword arguments — spells
the SAME contract as a zero-value sentinel default + an absence guard:
python def user_event(self, event: str | None = None) if event is not None: p["event"] = event
cpp Action user_event(const std::string& event = "") if (!event.empty()) { p["event"] = event; }
A caller who omits the argument produces the identical wire frame either way.
Recording `default: ""` against the reference's `default: null` manufactured
drift out of two spellings of "absent", so the enumerator now folds the sentinel
at the emitter — the comparison keeps running, which an allow-list entry would
have stopped.
THE FOLD IS EVIDENCE-GATED, and deliberately not "empty string always means
null". A port that defaults `prompt=""` and then SENDS `prompt: ""` ships a
different request body than a reference that omits the key. The fold requires
BOTH a type-appropriate sentinel AND a guard in the definition body that tests
it and suppresses the value's use. No guard, no fold — the sentinel is then a
value the port really ships and the finding stands.
Strings get a stricter rule than containers, measured against the oracle: `[]`
and `{}` never appear as a reference default (0 of 1,505), so a guarded empty
container is unambiguously "absent"; `""` is a REAL reference default 111 times.
The port models that distinction itself — `pom::Section` declares
`std::optional<std::string> title` (ref `str | None`) beside `std::string body`
(ref `str = ""`) — so a string folds only on a DIRECT guard in the method's own
body, never on the store-then-guard-at-serialization hop that `body` uses. Without
that split the fold turned the 5 POM `body` params into nulls, inventing a
`"" vs null` mismatch pointing the other way.
Also unions `required`/`default` across equal-arity overloads. C++ cannot repeat
a default on the typed `enum class` sibling of a flat `std::string` overload —
two equal-arity overloads both callable with fewer arguments are ambiguous — so
the typed `record_call`/`tap` forms declare `control_id`/`stereo`/`format`/
`direction`/`codec` bare, and dedup (which prefers the typed form for the
closed-set contract) reported `required: true` for seven parameters a caller can
plainly omit. Optionality is a property of the METHOD NAME, not of one overload.
Guards are read by a brace-matched text scan of src/ + the headers rather than by
re-parsing with libclang: the enumerator parses headers with
PARSE_SKIP_FUNCTION_BODIES for a 3-10x SIGNATURES speedup, and re-parsing all 67
TUs to read bodies would give that entire saving back.
DRIFT, my classes: 78 -> 18 (62 default-mismatch -> 9, 15 required-flip -> 8,
1 default-invented unchanged). 97 params folded; 93 verified param-by-param
against the reference as correct, 0 wrong. Gate-visible drift 45 -> 8 with zero
regressions. Failing set unchanged at {SURFACE, PUBLIC-JARGON}.
The 18 that remain are REAL port divergences, left for the behaviour phase:
* RelayClient::dial declares (devices, tag, dial_timeout_ms, max_duration)
where the reference is (devices, tag, max_duration, dial_timeout) — a
parameter ORDER divergence, so `dial_timeout_ms = 120000` lands on the
reference's `max_duration` slot.
* enable_debug_events(bool enable = true) vs the reference's `level: int = 1`.
* Section::add_subsection(std::optional<bool> numbered) makes a binary
reference flag (`numbered: bool = False`) tri-state.
* Step::set_gather_info's three params and add_gather_question(prompt) store
into plain `std::string` members and are only guarded at serialization —
indistinguishable from the real-empty-string `body` shape. Closing them means
modelling absence in the PORT (`std::optional<std::string>`, as
`pom::Section::title` already does), not loosening this rule.
* load_skill / on_swml_request / replace_in_history / on_function_call take
genuinely different or non-omittable parameters than the reference.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…default/required divergences, two of them real wire bugs Phase 1 (281ca7f) folded the null<->sentinel vocabulary at the ENUMERATOR. These 18 are the cases that rule deliberately refused to fold, because folding them without changing the port would have been a confident wrong value. Each is closed by changing the PORT to model absence, or by following the reference's shape. default-mismatch (9) * Step::set_gather_info(output_key, completion_action, prompt) and Step::add_gather_question(prompt) were plain `std::string = ""`, which the fold rule cannot distinguish from a real empty-string default (`""` is a real reference default 111 times). They are now `std::optional<std::string>`, as the reference's `str | None = None` declares. GatherInfo/GatherQuestion store them as optionals too. Serialization keeps the reference's TRUTHY guards (`if self._prompt:`), so an explicitly-empty string is still omitted. ContextBuilder::validate now skips completion_action only when it is ABSENT, matching the reference's `if action is not None:` — an explicitly-supplied value is validated even when empty. Error messages updated accordingly. * AgentBase::prompt_add_subsection(bullets) was `std::vector<...> = {}` with no guard. Now `std::optional<std::vector<std::string>>`, applied as `value_or({})` — the reference's `bullets or []`. (PomBuilder::add_subsection already used this shape and already compared equal; it is the proven pattern.) * pom::Section::add_subsection(numbered) made a BINARY reference flag tri-state. The reference's `Section.__init__` and `PromptObjectModel.add_section` really are `bool | None` — but `Section.add_subsection` declares `numbered: bool = False` and passes it through, so a subsection built that way is never None. The distinction is load-bearing in render_markdown (`subsection.numbered is not False` lets an unset sibling inherit numbering; an explicit false opts out), so cpp was inheriting numbering where the reference does not. * RelayClient::dial(dial_timeout) defaulted to a concrete 120000. The reference defaults to None and substitutes 120.0 in the body. Now `std::optional<double> = std::nullopt` with `value_or(120.0)`. required-flip (8) + default-invented (1) * Call::leave_conference(conference_id) defaulted to `""` and GUARDED it out of the frame. The reference declares it required and always sends it — so a cpp caller who omitted it silently emitted a leave_conference naming no conference. Now required, always on the wire, with a mock-backed wire test. * FunctionResult::replace_in_history(text) — added the reference's `= true` default (true = drop the tool_call+result pair entirely). * Service/AgentBase::on_function_call(raw_data) — added `= nullptr`, the reference's `raw_data: dict | None = None`. * SkillManager::load_skill was `(skill_name, params, agent)`; the reference is `(skill_name, skill_class=None, params=None)` on an agent-BOUND manager. Reshaped to match: both trailing params optional, `skill_class` spelled as a SkillFactory (C++'s class object) that short-circuits the registry lookup exactly as the reference's does, `params` normalised to `{}`. A manager with no bound agent now fails LOUD instead of loading into an argument. * InfoGathererAgent::on_swml_request was `(request_data, query_params, headers)` — three REQUIRED params under names the reference does not have. Now `(request_data, callback_path, request)`, all optional, reading query_params/headers OFF the request object the way the reference does. Parameter ORDER — RelayClient::dial Not a defaults problem. cpp had `(devices, tag, dial_timeout_ms, max_duration)`; the reference has `(devices, tag, max_duration, dial_timeout)`. A positional third argument therefore meant the OPPOSITE thing in this port. Reordered to the reference, and the unit follows it too: dial_timeout is SECONDS, not milliseconds. This is a breaking change for positional callers; all 18 call sites are updated. The max_duration wire test now documents the order it guards, and a new test pins the seconds unit and proves the timeout never leaks onto the wire as a max_duration. Real wire bug — debug events AIConfigMixin::enable_debug_events took `bool enable = true`; the reference takes `level: int = 1`, a verbosity LEVEL a bool cannot express. Following the type through found the emission was wrong as well: cpp emitted `ai.debug_events = true`, a key that appears neither in the reference nor in swml/schema.json. The reference wires the debug webhook into `ai.params` as `debug_webhook_url` + `debug_webhook_level` (agent_base.py:1248-1261). Fixed both, and mounted the `/debug_events` endpoint the advertised URL points at so it resolves. Both fields are copied onto the ephemeral agent, as the reference does. Verification bash scripts/run-tests.sh -> 2067 passed, 0 failed bash scripts/run-ci.sh -> exit 1, sole failing gate PUBLIC-JARGON (pre-existing skill_registry leak from f0b5df5/#106, not touched here). SURFACE now PASSES. diff_port_signatures.py -> default-mismatch 9->0, required-flip 8->0, default-invented 1->0; total drift 500 -> 479. Mutation-tested each kind (revert -> RED -> restore): the default level, the dial parameter order, the load_skill params default, and the leave_conference wire key each fail a specific test when reverted. Tests exercise the OMITTED path, not just the supplied one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…d one opaque param, and two shipped the wrong wire key
Python spells "these N knobs are individually optional" as N keyword params.
C++ has no keyword arguments, so the same contract is carried by ONE object
parameter — a typed options struct (RenderOptions) or an untyped `const json&`
bag. The construction contract already unfolded the struct form; ordinary
METHODS carried the identical idiom and were excused instead, by nine
PORT_SIGNATURE_OMISSIONS entries that stopped the differ comparing those
methods at all. Measured: they suppressed 16 real findings.
_project_options_carrier folds it at the emitter, so comparison keeps running.
Two carrier forms, both evidence-gated — never on the parameter's type alone,
because the reference has genuine DOMAIN parameters whose value simply IS a
dict (execute_swml(swml), refer(device)) and unfolding one would invent params
the port never had:
* TYPED-STRUCT — the param's type resolves to a known options struct, and
only the reference keywords the struct DECLARES a field for are unfolded.
* UNTYPED-BAG — the param is `const json&` AND the method body is proven to
SPREAD it onto the outgoing frame (`json p = params.is_object() ? params
: json::object()`), so every key the caller supplies reaches the wire and
the reference's names are genuinely reachable through it. A body that
reads fixed keys out of a dict is consuming a domain value and is skipped.
Three over-fires were caught by measuring rather than assuming, and each is now
a gate with its measurement recorded in the code:
* a REQUIRED carrier is not an optional-knob bag. define_tool(ToolDefinition)
and add_language(LanguageConfig) carry the reference's REQUIRED params too;
an optional-only unfold consumed the carrier and destroyed that surface,
turning define_tool(tool) into define_tool(secure).
* __init__ belongs to build_construction, which unfolds the struct's WHOLE
field set on purpose. Folding it here first silently dropped RelayConfig's
port / max_connections / request_timeout_ms from RelayClient.
* a JSON bag cannot hold a std::function. Unfolding a reference CALLABLE out
of one would claim a callback the port does not accept — it invented
on_completed on detect_answering_machine and transcribe. Those stay
missing, which is the honest result.
THE REAL DIVERGENCE UNDERNEATH — two methods shipped the wrong wire key.
Retiring the omissions exposed what they were hiding. The reference remaps two
API names onto the wire key `params`: bind_digit's `bind_params`
(relay/call.py:1359) and amazon_bedrock's `ai_params` (:1502). Every OTHER knob
these two offer is spelled the same on the API and the wire, so the bag carried
them correctly — but these two cannot ride in a verbatim-spread bag at all. A
caller had no way to set them: putting the value under its API name shipped
`bind_params` / `ai_params` as the wire key, which the server does not accept
(relay_apis.c:1479 and :1982 both list `params`). Both now take an explicit
std::optional<json> parameter that does the remap exactly as the reference
does, omitted entirely when unset (reference guard: `is not None`). The bag
stays in its existing position so no current call shape changes meaning.
Four mock-relay wire tests pin the remap and the omit-when-unset behaviour;
negating the two assignments fails exactly those two and nothing else.
The seven relay.call.Call.* entries folded as ONE mechanism, as expected. Their
`dict` vs `Action` return divergence is a SEPARATE, pre-existing mechanism
already carried by eleven sibling entries, so those seven are narrowed to the
established cpp_typed_return tag rather than deleted. The two non-Call entries
(AIVerbHandler.build_config, SwmlRenderer.render_swml) are fully dead and
deleted — build_config had a typed overload with the exact reference names all
along, and render_swml's RenderOptions struct declares every one of them.
drift 170 -> 170 (0 new findings, param-property drift stays 0)
excused 862 -> 851
Verified: run-ci failing set is {PUBLIC-JARGON} only — unchanged from baseline
(that leak is another lane's f0b5df5). SURFACE, TYPE-EROSION, GEN, LINT, FMT
and 2074/2074 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
An AST audit of every relay module for `<wire key>` <- `<differently-named API parameter>` found SEVEN sites in call.py, not the two that 50cdd9c fixed: :567 play() media -> "play" :844 play_and_collect() media -> "play" :1024 pay() input_method -> "input" :1260 join_conference() stream_obj -> "stream" :1359 bind_digit() bind_params -> "params" (already pinned) :1479 ai() ai_params -> "params" :1502 amazon_bedrock() ai_params -> "params" (already pinned) All seven are confirmed against the server: relay_apis.c lists "input" for pay (:1595, values dtmf,voice), "stream" for join_conference (:1758), "params" for bind_digit (:1479) and amazon_bedrock (:1982). The five newly-pinned sites are NOT unreachable in C++, and the reason is structural: where the reference exposes a named keyword whose value it re-keys, C++ either already names the parameter and re-keys it identically at the emitter (play / play_and_collect), or takes an untyped options bag spread verbatim, so the caller writes the wire key directly and it lands there unchanged (pay / join_conference / ai). That is only safe when the wire key is a legal bag key -- which is exactly why bind_digit/amazon_bedrock needed their own parameter: their wire key "params" collides with the bag's own name and could never be expressed. Mutation-proved, all seven: - flipping "params" back to "bind_params"/"ai_params" -> exactly the 2 remap tests red (126/128); - always-emitting the key instead of guarding -> exactly the 2 omit tests red; - renaming play/input/stream/params on emit -> the 5 new tests red (the mock relay itself rejects the wrong play key: "'play' is a required property"). Restore was byte-clean each time. Tests only; no emitter or header change, so port_signatures.json is untouched. 133/133 relay_mock tests green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ield, and fields-only PODs never reached the inventory
porting-sdk dcff742 resolved BasicCredentials/BearerCredentials into both oracles.
cpp compared as 4 HARD drifts against them (not 0 — that reading came from the
committed blob, where absence reads as agreement):
BasicCredentials.{username,password} missing-port
BearerCredentials.{scheme,credentials} missing-port
Two independent causes, both real.
1. BearerCredentials genuinely LACKED `scheme`. The reference's FastAPI
HTTPAuthorizationCredentials carries both halves of the Authorization header;
cpp carried only the credential string. Adding the field changes aggregate
init, so bearer_ok() and its test are fixed in the same commit — otherwise
BearerCredentials{auth.substr(7)} would bind the TOKEN to `scheme`.
2. enumerate_signatures gated inventory admission on `if methods:`. Both carriers
are fields-only PODs (two std::string members, zero methods), so they went to
options_structs and never to entries — absent from the artifact entirely. The
class:signalwire.core.basic_credentials.BasicCredentials string in the old
artifact was only a param-type translation of a class that was never emitted.
Admission is now gated on the reference ORACLE recording the class, so it cannot
invent surface. Generated DTOs are excluded BY PATH and that exclusion is
load-bearing, not cosmetic: CLASS_MODULE_MAP is keyed by bare class NAME, so the
generated REST DTO ...::messages::Message and SWML verb ...::DataMap resolve to
the SAME canonical key as the hand-written relay.message.Message /
core.data_map.DataMap. Admitting them does not add a class — it MERGES their wire
fields into the hand-written classes' construction contracts (measured: +13 bogus
params on Message; DataMap LOST its real function_name).
The surface half reuses the existing oracle-gated _emit_oracle_gated_fields, the
same fold already used for the AI-chat DTOs and typed relay events.
PORT_SIGNATURE_OMISSIONS.md: the 2 verify_* entries are DELETED, not rewritten.
Both verify_* signatures now compare byte-equal to the oracle, so the entries
excused nothing — the differ reports zero findings on either symbol. No
omission/addition/allow-list entry was created anywhere.
Measured with a PINNED oracle copy (python_signatures.json md5
7cb4b078b5b7da349ae686e24d084813), fresh regen on both arms:
hard drift 4 -> 0, none introduced
excused 1006 -> 1008: the 2 construction-missing-class retire; 4
construction-required-flip take their place on the same fields
(report-only — C++ aggregate init makes every field optional by
construction, a genuine language property, not a gap)
surface unexcused_missing 6 -> 0; unexcused_extra 38 -> 38 byte-identical
(pre-existing SWMLService backlog, untouched)
Nothing outside the credential surface moves. SURFACE-DIFF passes; SURFACE-FRESH
needed these two regenerated artifacts, which are committed here by content.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…30 classes, not just the 2 credential ones porting-sdk 8828dd2 taught the surface oracle to record a SYNTHESIZED __init__, closing a disagreement where python_signatures.json listed a constructor and python_surface.json did not. The surface enumerator's enrichment pass required a zero-arg member, so __init__ could never come from it; a class whose constructor is never spelled `def` — a @DataClass, or a structural filler with no source file — therefore lost it. griffe saw it, the AST walker could not. That lands on this port as 30 SURFACE-DIFF missing symbols. Measured against 2c6cf6d (i.e. BEFORE any of my work) with the CURRENT oracle, to separate what I own from what I inherited: 2c6cf6d : unexcused_missing 36, unexcused_extra 38 this HEAD: unexcused_missing 0, unexcused_extra 38 (extra set byte-identical) So all 36 resolve and none are introduced. Two of the 30 are the credential carriers from the previous commit; the other 28 — 19 typed relay events, 3 ai_chat carriers, RequestOptions, … — were mismatched in exactly the same way for exactly the same reason, and are fixed by the same fold rather than 30 special cases. The fold is oracle-gated like every other member in _emit_oracle_gated_fields: the port claims __init__ only where the reference records one. And the claim is TRUE of the port, not paperwork — these are C++ aggregates with no user-declared constructor, aggregate-initialized by field name. std::is_default_constructible is true for every one (probed: BasicCredentials, BearerCredentials, RelayEvent, PlayEvent, RequestOptions). SURFACE-DIFF still exits 1, on the 38 unexcused SWMLService.* EXTRAS. That set is unchanged and pre-existing — it fails identically at 2c6cf6d — and is a separate backlog, not something this commit or its parent touched. Verified: DRIFT clean against the advanced oracle (python_signatures.json md5 0c11202ace94c1f84ac995af35bc69ed) — 1561 reference symbols, 1023 excused, zero hard drift. SURFACE-FRESH FRESH. Tests 2079/2079. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
… is the real verb
`Call::prompt` was a one-line forwarder to `play_and_collect`, which does the
actual work and emits the wire frame (p["play"], p["collect"]). The reference
(signalwire/relay/call.py) declares prompt_tts, prompt_audio and
play_and_collect — there is no bare `prompt`. So play_and_collect is canonical
and `prompt` was port-only surface.
§3.0 does not accept "alias" as a valid addition reason, so this is deleted
rather than excused. Both PORT_ADDITIONS entries go with it, and both were
wrong about which direction the alias ran:
* the play_and_collect entry claimed play_and_collect was "an alias for
prompt" and that "C++ keeps prompt as the documented method" — inverted,
and self-contradictory, since its own next sentence admits Python has
Call.play_and_collect;
* the Call.prompt entry filed it as "cpp_typed_accessor: const-ref accessor
or state predicate", which it never was — it is an Action-returning verb.
That rationale is bulk boilerplate applied verbatim across a run of
consecutive Call entries.
The one call site (tests/test_relay.cpp) was a test of the alias itself, in a
sweep asserting each verb reaches the wire; it is retargeted at
play_and_collect so the coverage is kept rather than dropped. The wire
behaviour of play_and_collect is separately pinned by the mock-backed tests in
test_relay_mock_actions.cpp / test_relay_mock_convenience.cpp.
port_signatures.json and port_surface.json regenerated. Surface additions drop
400 -> 398; excused omissions unchanged at 39; drift stays zero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…ce payload
Owner ruling 2026-07-28: every port declares 3.0.0, and the release floor
(port_signatures.baseline.json) is re-anchored to that wave.
Two halves, both required. Setting baseline_version alone does NOT satisfy
SEMVER-DIFF: the gate does not compare version strings, it DIFFS the current
surface against the floor's recorded `modules` payload and sets required='major'
whenever they differ (semver_diff.py:335). Neither 'none' nor 'downgrade' can
satisfy 'major' (both rank -1 at :528). So the payload is replaced too.
CMakeLists.txt project(signalwire VERSION 3.2.1 -> 3.0.0)
port_signatures.baseline.json baseline_version 3.0.2 -> 3.0.0
modules 87 -> 92 (+ construction), copied from
a FRESH regen of port_signatures.json
generated_from / generated_from_commit
re-anchored 9817418 -> this branch's HEAD
The version declaration site is `project(<name> VERSION ...)` on line 2 — the
same site semver_diff.py documents for cpp and matches with its CMakeLists regex,
and the same site CMakeLists itself names as "the single version source", from
which cmake/version.hpp.in generates signalwire/version.hpp. The
`cmake_minimum_required(VERSION 3.16)` on line 1 is a TOOLCHAIN floor, not the
SDK version, and is untouched.
This is a PAYLOAD SWAP, not a file copy — the floor carries release-anchor
metadata (baseline_version, generated_from_commit) the current artifact does not,
and every one of those keys is preserved.
What it means semantically: 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 ever shipped — cpp's published tags top out at v2.0.1 — so
the downgrade regresses no artifact. The consequence to be explicit about is
that SEMVER-DIFF will no longer flag anything already present in today's
surface; future breaking changes are still caught, now measured against the new
floor.
This sets version INTENT only. No tag, no release. The CHANGELOG's "## [3.2.1]"
heading is a historical release entry, not a declaration site, and is left as-is
(same call the typescript and php lanes made today).
Verified: semver_diff.py --port cpp exits 0 —
[semver-diff] cpp: 3.0.0 (3.0.0) -> 3.0.0 (CMakeLists.txt)
actual bump = 'none', required = 'none' [ok]
No SEMVER_DIFF_ALLOW.md entry was added.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
Nothing above v2.0.1 was ever published, so the 3.0.2/3.1.0/3.2.0/3.2.1 headings were never a release history. Collapsed into a single 3.0.0 entry, merging by subsection (Fixed / Added / Changed / Release engineering) so every bullet is preserved in its own category. Owner ruling 2026-07-28. Fixes META-CONSISTENT version-vs-changelog.
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.
…cation The mcp_gateway skill made its gateway HTTP calls with no control over TLS server-certificate verification, and advertised no verify_ssl parameter. The reference's MCPGatewaySkill exposes verify_ssl with a SECURE default (true). - get_parameter_schema now advertises verify_ssl (type boolean, default true) alongside gateway_url / tool_prefix / request_timeout, matching the reference. - setup parses it with a secure default: get_param<bool>(params, "verify_ssl", true). - WIRED, not merely declared: call_gateway() passes it to enable_server_certificate_verification(), so every gateway call verifies the server cert unless the operator explicitly opted out. SIGNALWIRE_REST_CA_FILE is honoured for a custom CA with verification left ON. Tests are behavioral, not schema-only: an in-process HTTPS gateway is stood up with the shared test cert (signed by the test CA, deliberately NOT trusted), and the suite proves the default REJECTS it while verify_ssl=false accepts it. A schema assertion alone would pass even if the flag were ignored. Note on scope: this lands only the mcp_gateway source + its tests. The original commit on the stale wave/1-aplus branch also removed a duplicate McpGatewaySkillR stub from skill_registry.cpp; main has since deleted ALL 18 such duplicate stubs in a broader fix, so that hunk is obsolete and carrying it would revert the larger change. Verified: run_tests 2082/2082, with skill_mcp_verify_ssl_default_verifies and skill_mcp_verify_ssl_false_disables_cert_check both EXECUTING (not skipped — they self-skip when the porting-sdk test CA is unreachable, which silently hides them). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…re TOKEN-INTEROP
base64url_encode popped the trailing '=' padding — while its own header comment
claimed it matched Python's base64.urlsafe_b64encode, which KEEPS the padding.
The reference validates with base64.urlsafe_b64decode, which 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
that means every secure tool call fails authentication.
Our own base64url_decode kept accepting them because it re-pads before decoding.
That encoder/decoder asymmetry is exactly why round-tripping a token against
ourselves could never surface the bug, and why the new gate validates against the
REFERENCE's decoder rather than our own.
Also wires the TOKEN-INTEROP gate (property 3 of the SWAIG tool-token contract: a
token this port MINTS validates under the reference's decoder). SECURE-DEFAULT
proves a token is minted and the keying check proves the HMAC key; neither sees the
base64 ENVELOPE. tools/token_interop_mint.cpp mints one token from the fixed inputs
the checker exports and prints just that token; the binary is built alongside the
other dump binaries in the TEST gate (local, exec: and run: build modes all
updated). Per-PR rather than nightly — a security property should not wait.
This is the third port found with this exact defect (java and perl are the others),
so the gate is closing a real fleet-wide class, not a one-off.
Verified: TOKEN-INTEROP exit 0 with the fix; re-introducing just the padding strip
reproduces "base64 envelope is not decodable the way the reference decodes it /
urlsafe_b64decode raised Error('Incorrect padding')", so the gate fails for the
right reason. run_tests 2082/2082.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…d it PUBLIC-JARGON flagged two shipped doc comments in skill_registry that explained the cross-port AUDIT rather than the C++ behaviour: they cited "the surface enumerator", "parity was being measured", and "parity can pass against dead code". None of that means anything to someone reading the header to learn what register_skill does, and it leaks internal porting vocabulary into published API docs. The technical content is kept and is genuinely useful — duplicate registration is undefined-order across translation units, so a shadowing skill could win by link order and change between builds with no source change; register_skill therefore THROWS. The list of what the old shadow copies were missing stays too, since it explains why they were deleted rather than merged. Verified: public_jargon -> "cpp: clean (scanned 1259 source file(s))"; run_tests still builds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
`DataMap::expression` emitted `nomatch_output`. The reference emits
`nomatch-output` (data_map.py:202), and behavioral_manifest.yaml:3443 records the
same `nomatch-output?`. An underscored key is one the server does not recognise,
so the no-match branch of every DataMap expression silently never fired.
datamap_expression_with_nomatch WAS VACUOUS IN THE WORST WAY — it asserted only
`contains("nomatch_output")`, which is exactly the buggy key, so it passed against
the defect and would have passed against nothing else. It now asserts the
hyphenated key, that the underscored one is ABSENT, and the VALUE that lands
there, so the divergence cannot come back green.
FLEET CONTEXT — found by sweeping all nine ports, not just this one:
correct already: java, go, typescript, ruby, perl
same bug: cpp (here), dotnet (c9e0e3c), rust (92259a8), php (8e13640)
No gate catches this class of divergence.
Verified: scripts/run-tests.sh datamap_ -> exit 0, all datamap_* OK.
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 122 -> 85 (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 cpp --repo . --max 85 -> exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…tch the reference shape
TWO defects, one of them live.
1. THE SCRUB WAS NEVER CALLED. `strip_control_chars` was public, correct, and had
ZERO call sites — grep the whole tree and the only hits are its own declaration
and definition. `Logger::log` streamed the caller's message to cout/cerr
verbatim, so a `\x00`, a `\x07`, or an `\x1b[` escape reached the terminal
intact. The reference registers this scrub in BOTH of its structlog processor
chains (logging_config.py:205,233): there it is real protection; here it was a
function nobody invoked. Log-injection defence present in name only.
2. WRONG SHAPE. The reference's public contract takes the event MAP and scrubs
every string value; this port took a single `std::string`. That is a different
function — a caller could not hand it a log event and have the values
sanitised.
strip_control_chars(const std::string&) -> strip_control_chars_str, INTERNAL
(the per-value scrub, the unit the
emitter needs; not port surface)
strip_control_chars(const json&) -> NEW public form, matching the
reference: scrubs string values,
passes non-strings through
(the `isinstance(value, str)` guard)
Logger::log now scrubs before streaming
Fixing only the signature would have turned every gate green while leaving the
emitter unprotected — a certified-correct signature in front of a defect.
THE TESTS ARE THE POINT, and there were NONE before this: `strip_control_chars`
shipped with no test at all. The new emission test captures what `Logger::log`
ACTUALLY writes (swapping `std::cout`'s rdbuf) rather than calling the scrub
helper directly, so it fails when the wiring is removed. Verified by deleting the
scrub from the emitter — the test goes RED on the control-char assertion:
FAIL: line.find(bad) == std::string::npos (test_logging.cpp:171)
A helper-only test passes against that same break, which is exactly how this
shipped unprotected.
Also asserts tab/newline/CR SURVIVE — a scrub that ate them would satisfy "no
control chars" while mangling every multi-line message.
The two emission tests unsuppress the logger singleton for their own scope and
restore it after (test_main.cpp:236 suppresses it for the whole run), so no
sibling test observes the change.
Verified: scripts/run-tests.sh -> exit 0, full suite.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KKwgPbehDoAMdG3hPL79oi
…e written against
SNIPPET-COMPILE failed on 2 of 523 cpp snippets, both the same call:
prompt_add_subsection(...) passed a bare braced list {"a","b"} where the parameter is
std::optional<std::vector<std::string>>. A braced-init-list cannot deduce through
optional's converting constructor, so it is a hard error, not a warning.
The cpp signature is the faithful port and was NOT changed:
include/signalwire/agent/agent_base.hpp:207-209
const std::optional<std::vector<std::string>>& bullets = std::nullopt
reference core/mixins/prompt_mixin.py:297-302
bullets: list[str] | None = None
Corroborated by the repo's own tests/test_prompt.cpp:116, which already uses the correct
std::vector<std::string>{...} form.
THE ROOT OF THE DRIFT was not the two call sites. docs/api_reference.md:244-255 documented
a DECLARATION THAT DOES NOT EXIST — `const std::vector<std::string>& bullets = {}` — so the
examples were written against a signature the docs had invented. Fixing only the call sites
would have left the declaration lying and the next example would have been written wrong
again. The declaration block and its parameter row are corrected here too, along with a
method-summary line in agent_guide.md:942 that said `bullets = {}` instead of `std::nullopt`.
Swept every other doc call site against an optional-vector parameter: set_valid_steps,
set_valid_contexts, prompt_add_to_section, prompt_add_section and set_native_functions all
take a plain const std::vector<std::string>&, where braced-init is legal. prompt_add_subsection
is the only optional-typed one reachable from a snippet.
Verification:
snippet_compile.py --port cpp --repo . -> exit 0
"cpp: clean (523 compiled)" (was: 2 compile failure(s))
0 allowlisted / 42 suppressed BOTH BEFORE AND AFTER — the green came from fixing snippets,
not from adding a no-compile marker or an allow entry.
run-format.sh -> exit 0, changed nothing.
Noted, not touched: prompt_add_section / prompt_add_to_section take plain vector<string> = {}
where the reference has list[str] | None = None, so they do not mirror their sibling. Already
carried in PORT_SIGNATURE_OMISSIONS.md:283-284,297-298 as cpp_typed_overload_subset and it
causes no failure — flagged only so the three-way inconsistency is visible if that tag is
revisited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
… WAIT-LIVENESS ran nothing
BEHAVIORAL-NIGHTLY was red. WAIT-LIVENESS was the only failing rule; RELAY-LIVENESS and
SECRET-SCRUB-LIVE both passed in the same run.
It is NOT a behavioural regression and NOT a previously-vacuous check now biting. It is a
BUILD-WIRING regression: the `wait_liveness_dump` binary was never built, so the gate executed
a nonexistent program —
wait-liveness dump did not run — exit=127, 0 bytes stdout
— and correctly failed. The port's wait() liveness behaviour was always right; it passes the
moment the binary exists.
ROOT CAUSE: commit 820d500 ("feat(ai-chat): C++ AIChatClient + wire-behavioral gate", #52) added
`ai_chat_dump` to run-ci's `cmake --build --target` list and in the SAME edit dropped
`wait_liveness_dump`, in all three build modes. That silently reverted 3f67c11 ("fix(ci): build
wait_liveness_dump for the WAIT-LIVENESS gate", #50), which had added it for exactly this gate.
Confirmed by `git log -S wait_liveness_dump -- scripts/run-ci.sh`.
It hid locally because a stale build/wait_liveness_dump from Jul 27 was still on disk while every
sibling dump was Jul 28. A clean CI runner has no leftover, hence exit 127.
Restored to all three build modes (local, exec:, run:). No source, no test, no gate rule changed.
Verification:
python3 porting-sdk/scripts/suites/behavioral.py --port cpp --repo . \
--rules WAIT-LIVENESS,RELAY-LIVENESS,SECRET-SCRUB-LIVE -> exit 0
[BEHAVIORAL:WAIT-LIVENESS] ... PASS
[BEHAVIORAL:RELAY-LIVENESS] ... PASS
[BEHAVIORAL:SECRET-SCRUB-LIVE] ... PASS
[BEHAVIORAL] all 3 rules PASS
Pre-fix reproduction was byte-identical to the nightly's failure line.
run-format.sh -> exit 0, no change of substance.
A PRIOR FINDING IS REFUTED BY THIS WORK: task #96 records "SECRET-SCRUB-LIVE passes VACUOUSLY in
go, cpp, ruby". That is FALSE for cpp. Probed with a deliberately missing binary, the rule exits 1
with "✗ cpp: secret-scrub dump did not emit valid JSON" — it is non-vacuous, and its PASS is real
because secret_scrub_dump IS in the build list. The go and ruby halves of #96 should be re-checked
before being acted on.
NOT FIXED, worth its own item: nothing guards this defect class. WIRED-MODES passed but does not
cover the dump-target list, so any PR can silently drop a dump target and only a nightly notices.
A per-PR check that every --dump-cmd path in _behavioral_commands.py appears in run-ci's --target
list would close it, and the same coupling is unguarded in every port that builds dump binaries.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Part of the fleet-wide false-ledger sweep. DRIFT reports 1025 excused divergences before AND
after — the 34 signature deletions were excusing nothing.
FOUR CLAIMS IN MY OWN BRIEF DID NOT SURVIVE SOURCE:
- "~47 entries" — PORT_SIGNATURE_OMISSIONS.md has 267.
- "a block around line 216" — line 216 is the cpp_typed_builder_infer VOCABULARY paragraph, not
an entry block. The real fabricated block is the 24 from_payload entries at lines 426-449.
- "--gates SURFACE,LEDGER is sufficient" — it is not, and this is load-bearing:
PORT_OMISSIONS.md is read by BOTH diff_port_surface.py AND diff_port_signatures.py (as
--surface-omissions, a cross-excuse). Probing one consumer is not probing the ledger.
- "SWMLBuilder.add_section/reset are the known carriers" — NOT carriers in cpp. Both are
genuinely live (return-mismatch 'class:Self' vs 'class:...SWMLBuilder'). Left untouched.
THE (c) TRAP BIT ONCE, AND THE PROBE CAUGHT IT. Seven PORT_OMISSIONS entries were deleted after
probing only diff_port_surface.py; the SIGNATURE gate immediately went red with 5 missing-port
drifts. Restored and re-probed against both consumers: only 2 were truly dead, and 5 were
dead-for-SURFACE / live-for-SIGNATURE and got reworded instead.
(a) FABRICATED — deleted (26)
24x signalwire.relay.event.*Event.from_payload
rationale: "Python spreads the decoded event fields as typed __init__ params."
The oracle records from_payload(cls, payload: dict[str,Any]) — a SINGLE payload param
(relay/event.py:23,44,73), matching cpp's from_payload(const json&)
(typed_events.hpp:32,48,74,98,120). The rationale describes __init__, a DIFFERENT MEMBER.
Judging the wrong member is the same error that produced the go mistake.
AIConfigMixin.add_pattern_hint — tagged "C++ ships fewer kwargs"; all four params
(hint/pattern/replace/ignore_case) are byte-identical on both sides.
relay.call.Action.result — rationale is about __init__/start_input_timers, not result.
(b) FIXED-BUT-NOT-DELISTED — deleted (10)
AIConfigMixin.enable_debug_events — entry said the level-of-detail modes are "not yet ported";
cpp declares level: int = 1, identical to the reference. (Also carried a §3-banned
"not yet" tag.)
pom.PromptObjectModel.sections, pom.Section.subsections, RequestOptions.abort_signal — all
three said "libclang emits no getter method"; all three are now recorded as methods in
port_signatures.json.
SpiderSkill.__init__, web_service.WebService — claimed missing; present on both sides.
4 redundant (AgentServer.agents, AgentServer.app, SkillManager.loaded_skills,
AgentBase.set_signing_key) — members cpp does not have, so the signature differ never
compares them; already covered by PORT_OMISSIONS/PORT_ADDITIONS.
(c) MISWORDED-BUT-REAL — reworded, NOT deleted (7)
RelayClient.dial — the marquee catch. Rationale: "dial_timeout: int uses 0 for no timeout;
Python uses Optional[float]." FALSE — cpp declares std::optional<double> dial_timeout =
std::nullopt in seconds (client.hpp:136), matching the reference exactly, and
client.hpp:120-135 documents the OLD shape in past tense. But a real divergence survives:
tag/max_duration/dial_timeout are keyword-only in the reference and positional in C++, and
devices is untyped `any`. Reworded to cpp_positional_kwargs.
relay.event.parse_event — same false typed-event-ctor text. The real finding is that cpp DOES
implement it (typed_events.hpp:558, matching signature); the libclang enumerator does not
record namespace-scope free functions. Reworded to cpp_free_function_not_enumerated.
4 built-in skills (ApiNinjasTrivia/PlayBackgroundFile/WeatherApi/WikipediaSearch) — "not
exposed by name in C++" is false (e.g. search_wiki at
src/skills/builtin/wikipedia_search.cpp:38). The classes live in .cpp TUs with no public
header, and enumerate_signatures.py walks include/ only. Reworded to
cpp_builtin_skill_in_tu.
FunctionResult.to_dict — port has to_json() (function_result.hpp:421); SURFACE aliases it,
SIGNATURE does not. Reworded to state the dual-gate asymmetry.
Also removed 3 orphaned vocabulary definitions (cpp_dial_int_timeout,
cpp_idiom_optional_int_timeout, cpp_debug_level_bool). Deleted outright, no tombstones — a
tombstone keeps the symbol name and the stale claim greppable.
FOR AN OWNER — TWO ENUMERATOR DEFECTS, NOT PORT BUGS. Seven surviving entries are now permanent
blind spots caused by signature-enumerator SCOPE, not by any language limitation:
1. enumerate_signatures.py walks include/ only, so the 4 built-in skill classes in
src/skills/builtin/*.cpp are invisible. Fix: widen the walk.
2. It does not record namespace-scope free functions, hiding parse_event despite a matching C++
implementation (the known "module-level free function" class, AGENT_RULES §5).
Fixing either would let those entries be DELETED rather than excused. The enumerator was not
touched — out of lane scope.
Separately, and invisible to DRIFT: pom.sections / pom.subsections now match only because cpp
returns `any` (the differ's wildcard) against the reference's list<Section>. That is real type
erosion. TYPE-EROSION's ratchet (max 85) computes independently of the ledger, so these edits are
neutral to it.
Verification:
BEFORE: SURFACE PASS · LEDGER PASS · NO-LAUNDER (impossible:18 approved:11 idiom:7) · exit 0
AFTER: SURFACE PASS · LEDGER PASS · NO-LAUNDER (impossible:18 approved:11 idiom:5) · exit 0
DRIFT standalone: 1025 excused divergences before and after.
The finding above — a deletion that red'd the SIGNATURE gate — is the load-bearing evidence,
not the green. NO cpp SDK source touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Seven sites read a port or a tuning knob out of the environment or argv with
std::atoi / std::atof. Neither can report a bad value: on anything non-numeric
they return 0, indistinguishable from a legitimate "0".
The consequences were real and silent:
kubernetes_ready_agent.cpp:28 PORT=abc -> port 0. In the deployment target
this example is named for, PORT comes straight
from the environment.
datasphere_serverless_env.cpp DATASPHERE_COUNT=abc -> a request for zero
datasphere_webhook_env_demo.cpp results; DATASPHERE_DISTANCE=abc -> 0.0.
swmlservice_swaig_standalone.cpp / swmlservice_ai_sidecar.cpp
a non-numeric argv[1] fell into the existing
`<= 0` guard, which quietly reset to 3000
without ever saying the argument was ignored.
All seven now use std::stoi / std::stod inside a try/catch that reports the
offending value and keeps the documented default. The port arguments still fall
back to 3000, but they say so.
Also, one deliberate empty catch is KEPT with a per-line suppression and its
reason: examples/relay_audit_harness.cpp:100. The echo there is best-effort --
the harness reports only whether an event was OBSERVED (the saw_event flag), so
a failed ack must not change the audit result or abort the callback.
Verified:
$ cmake --build build --target examples -j 8 -> exit 0, all 73 compile
$ clang-tidy over examples/ + rest/examples/ + relay/examples/
bugprone-unchecked-string-to-number-conversion 7 -> 0
bugprone-empty-catch 1 -> 0
total findings 134 -> 9
The 9 that remain are NOT burnable here and are reported as owner questions:
6 clang-diagnostic-overloaded-virtual, which is a real surface/parity gap in
include/ (InfoGathererAgent::on_swml_request HIDES rather than overrides the
base, so the virtual dispatch at src/swml/service.cpp:596 never reaches it --
the reference declares three params on both, the C++ base declares two), and 3
clang-diagnostic-mismatched-tags inside vendored deps/httplib.h, which
--header-filter cannot exclude because clang-diagnostic-* are compiler
warnings rather than clang-tidy checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
deps/ (httplib.h, json.hpp, nlohmann/) is vendored third-party code we do not
own, but CMake added it as an ordinary include directory. So the compiler
treated warnings raised INSIDE those headers as warnings about this repo:
building the example targets (the only ones compiled with -Wall) surfaced
httplib.h's own -Wmismatched-tags three times, attributed to us.
clang-tidy's --header-filter cannot exclude them, and that is not a bug in the
filter: clang-diagnostic-* entries are COMPILER warnings surfaced through
clang-tidy, not clang-tidy checks, so --header-filter never applies to them.
The only correct lever is the one the compiler provides for exactly this case.
`include_directories(SYSTEM ...)` (and the matching `target_include_directories
(signalwire SYSTEM PUBLIC ...)`, so consumers of the installed target inherit
the same treatment) is the standard mechanism for "third-party headers, not
ours to fix". Our own include/ stays deliberately NON-system: we do want its
warnings, and this change must not hide them.
This is the same boundary the rest of this rollout draws -- deps/ is the one
tree that legitimately stays outside the bar because we do not own it -- applied
at the compiler instead of via a suppression.
Verified:
$ cmake --build build -j 8 -> exit 0, 0 errors
$ cmake --build build --target examples -j 8 -> exit 0
$ clang-tidy over examples/ + rest/examples/ + relay/examples/
clang-diagnostic-mismatched-tags 3 -> 0
total findings 9 -> 6
The remaining 6 are clang-diagnostic-overloaded-virtual and are NOT hidden by
this change -- they are a genuine surface question in include/ that needs an
owner ruling (InfoGathererAgent::on_swml_request takes 3 params and returns
json; the base swml::Service::on_swml_request is virtual, takes 2 and returns
optional<json>, so the derived one HIDES rather than overrides and the virtual
dispatch at src/swml/service.cpp:596 never reaches it. The Python oracle
declares 3 params on BOTH WebMixin and InfoGathererAgent, so it is the C++ BASE
that is short a parameter. PORT_SIGNATURE_OMISSIONS.md:288 and :365 already
record the divergence as `cpp_typed_overload_subset` / `cpp_overload`).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Four more real defects in the test harness, all found once tests/ came under
clang-tidy.
1. EXCEPTIONS ESCAPING std::thread BODIES (bugprone-exception-escape).
tests/test_relay_mock_outbound_call.cpp ran two `pusher` threads whose bodies
call mt::journal_recv(), which does HTTP and can throw. An exception leaving
a std::thread body is std::terminate -- the ENTIRE suite aborts, with no
message and no indication which test did it. Both bodies are now wrapped and
report on stderr. (The capture lists carry a per-line suppression because
clang-tidy cannot see through the lambda boundary even once the body is
wrapped -- the same blind spot, and the same remedy, as the existing
suppression at src/web/web_service.cpp:205.)
tests/test_main.cpp's own main() is likewise wrapped: a throw out of the test
runner was an abort rather than a reported failure.
tests/test_url_validator.cpp:18's resolver stub is suppressed per-line
instead: the only thing that can throw is the vector allocation (bad_alloc),
which the stub cannot meaningfully handle and the test is not exercising.
2. std::system("mkdir -p ...") (bugprone-command-processor), 2 sites.
test_agent.cpp and test_config_loader.cpp shelled out to create a scratch
directory, discarding the result. Replaced with ::mkdir(dir, 0755) treating
EEXIST as success: no shell is spawned (so nothing in the path can be
interpreted) and a genuine failure is now reported instead of silently
producing a test that writes into a directory that does not exist.
3. THE REST OF THE setup() SWEEP (clang-diagnostic-unused-result), 28 sites.
The earlier pass only matched single-line `skill->setup(...)` statements;
these are the multi-line calls it missed, across 8 skill test files.
4. ASSERT_THROWS discarded a [[nodiscard]] result at 3 sites. Fixed once, at the
macro, rather than 3 times at the call sites. The macro must emit `expr` as a
STATEMENT -- callers also pass DECLARATIONS, e.g.
`ASSERT_THROWS(SessionManager sm(short_secret))`, which cannot be an argument
to a sink function -- so the discard is intentional and now says so with one
NOLINTNEXTLINE inside the macro definition.
Verified, per check, over tests/:
clang-diagnostic-unused-result 31 -> 0
bugprone-command-processor 2 -> 0
bugprone-exception-escape 4 -> 0
total findings 730 -> 693
Full suite: 2134 passed, 0 failed.
Everything still outstanding in tests/ is structural rather than burnable and is
reported as an owner question: 570 findings are artifacts of the ASSERT_*/TEST
macro expansions (measured, not estimated -- 319 of 320
performance-unnecessary-copy-initialization and 231 of 231
readability-simplify-boolean-expr come from inside the macros, not from test
source), and 123 bugprone-suspicious-include are the deliberate
one-translation-unit architecture in which test_main.cpp #includes 123 .cpp
files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Lands the scope widening now that the burn is at zero, in that order and never
the reverse: a gate that lands red trains everyone to ignore a red.
FMT src/ include/ tools/ -> + tests/ examples/ rest/examples/ relay/examples/
LINT src/ include/ -> + tools/ examples/ rest/examples/ relay/examples/
PY-LINT (new) -> scripts/*.py
Both C++ gates previously excluded these trees through a hard `find` path list
with no rationale recorded anywhere. The only exclusion this repo ever wrote a
reason for is vendored deps/ (.clang-tidy:70-74, .clang-format:12-13), and that
reason -- "third-party code we do not own" -- never applied to our own tools,
our own shipped examples, or our own tests. HeaderFilterRegex is widened to
match, so headers reached from the new trees are analysed the same way.
PY-LINT is new wiring, via the new canonical scripts/run-pylint.sh, and follows
the same dual-mode contract as FMT: LOCAL applies fixes, CI ($CI set) passes
--check. Nine hand-written Python files (~10.4k lines) had no gate at all, two
of them (_cpp_fmt.py, clang_tidy_cache.py) being the lint/format infrastructure
the FMT and LINT gates themselves run THROUGH.
deps/ stays out of everything, and scripts/clang_tidy_cache.py stays out of
PY-LINT, for the one legitimate reason: both are vendored third-party code.
deps/ is now excluded at the compiler (CMake SYSTEM include) rather than by a
path list, because clang-diagnostic-* are compiler warnings that --header-filter
structurally cannot reach.
NON-VACUITY PROVEN ON THE REAL GATE, not asserted. This is the failure mode the
campaign has been burned by five times -- a gate that analyses nothing and
reports green. Planted a deliberate violation in one newly-covered example and
one newly-covered tool:
$ bash scripts/run-lint.sh
EXIT=1
examples/simple_agent.cpp:80:12: error: unused function 'sw_probe' ...
examples/simple_agent.cpp:81:7: error: the 'empty' method should be used ...
tools/state_dump.cpp:84:7: error: the 'empty' method should be used ...
reverted:
$ bash scripts/run-lint.sh
EXIT=0, 0 findings
Findings land at the real file:line inside the new trees, so the widening is
analysing them, not skipping them.
$ bash scripts/run-format.sh --check -> exit 0 (all 1478 files)
$ bash scripts/run-lint.sh -> exit 0, 0 findings
$ bash scripts/run-pylint.sh --check -> exit 0 ("All checks passed!",
"8 files already formatted")
ruff is DECLARED in both layers per AGENT_RULES §7 -- a hint in scripts/_env.sh
for local devs, and `pip install ruff` in .github/workflows/{test,nightly}.yml
next to the pinned clang-format -- so a fresh clone or a CI runner has it rather
than it working only where it happens to be installed.
pylint_gate is registered in WIRED_MODES.md. It is exactly the shape the
strict-mocks merge race already destroyed once here (a surviving call line with
a dropped function body -> exit 127 instead of a real run), so the
merge-coherence guard now pins body + call.
tests/ is under FMT but deliberately NOT yet under LINT, recorded as an open
owner question rather than a silent carve-out, with the reasoning written at the
top of scripts/run-lint.sh and summarised in CLAUDE.md. Everything in tests/
outside three specific checks is burned to zero; what remains is structural:
570 findings come from inside the ASSERT_*/TEST macro expansions rather than
from test source (measured: 319/320 performance-unnecessary-copy-initialization
and 231/231 readability-simplify-boolean-expr), and 123
bugprone-suspicious-include ARE the single-translation-unit design documented at
CLAUDE.md:96. The obvious fix to the first -- binding ASSERT_EQ's operands by
const& -- was tried and FAILS 5 tests, because `mock.requests()[0].method` then
holds a reference into a by-value temporary; the copy is load-bearing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
Follow-up to the lint/format widening: three doc gates read the example sources,
so changing those sources moved what the gates see. All three are fixed at the
source of truth rather than allow-listed.
README-INCLUDE. README.md's three quickstart blocks must be byte-identical
(modulo common indent) to the named regions of examples/quickstart_{agent,relay,
rest}.cpp -- the point of the gate is that a doc block cannot rot because it IS
the compiled code. Wrapping those examples' main() in the exception guard, and
running clang-format over examples/ for the first time, re-indented the regions.
The blocks are regenerated FROM the fixtures.
Worth noting: the README blocks were ALREADY stale before this lane touched
anything -- they carried 4-space indentation and a different #include order than
the fixtures they claim to quote. The widening did not break them; it made an
existing drift visible. That is the gate doing its job.
IGNORE-LEDGER-VERIFY flagged two entries in DOC_AUDIT_IGNORE.md as stale, and it
was right -- this lane deleted their last references:
c_str the readability-redundant-string-cstr fix removed the last .c_str()
call the entry named (swmlservice_ai_sidecar.cpp).
atof replaced by std::stod, because atof cannot report a bad value.
Both pruned rather than kept. (atoi stays: still referenced, in the comments
explaining why it was replaced.)
DOC-AUDIT then wanted the replacements. stoi/stod are added in the same
stdlib-name category, and with the same shape, as the atoi/atof entries they
supersede -- a like-for-like swap in an existing category, not a new exception.
$ python3 porting-sdk/scripts/suites/doc_truth.py --port cpp --repo .
[DOC-TRUTH] all 8 rules PASS
$ python3 porting-sdk/scripts/ignore_ledger_verify.py --port cpp --repo .
[ignore-ledger-verify] cpp: clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
GEN-FRESH-TESTS and FMT were mutually exclusive on the generated REST test suite, which is the exact conflict AGENT_RULES §5 warns about: GEN-FRESH-TESTS byte-compares a fresh regen against the tree, FMT runs --check over the tree, and the generator emitted call lines longer than the 100-col limit. So clang-format rewrapped three files, and after that the tree could be gen-fresh OR format-clean, never both. One of the two gates was always going to be red. This was LATENT before this lane -- tests/ was outside the FMT scope, so nothing ever reformatted those files and the conflict never fired. Widening FMT to tests/ turned it into a hard red. It is not a regression in the generator; running it from its pre-lane version (6471631) produces byte-identical unwrapped output. Fixed at emit, per §5, so the formatter has nothing to do: out[fname] = clang_format_source("".join(parts), assume_filename=...) Uses the REAL clang-format (the `clang_format_source` backstop in _cpp_fmt.py), not the pure-python `format_generated_cpp` subset that generate_rest.py uses for headers. That subset targets DECLARATIONS and mis-wraps the `(void)(...)` cast these templates emit -- it breaks the line immediately after the opening paren, producing output clang-format then disagrees with: - ( - void)(client.video().conferences.list_conference_tokens("X", ...)); + (void)(client.video().conferences.list_conference_tokens("X", + std::map<...>{})); Shelling out to the same binary the FMT gate runs makes the two agree by construction rather than by coincidence. Verified -- both gates green simultaneously, which is the whole point: $ bash scripts/run-format.sh --check -> exit 0 $ python3 scripts/generate_rest_tests.py --check GEN-FRESH-TESTS: 14 generated REST test file(s) up to date. $ git status --short tests/ -> empty $ bash scripts/run-tests.sh -> 2134 passed, 0 failed $ bash scripts/run-pylint.sh --check -> exit 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The PY-LINT gate needs ruff.toml at the repo root: ruff discovers its config by
walking UP from the files it lints, so a copy under eng/ would never be found
when linting scripts/*.py. That is the same convention .clang-tidy and
.clang-format already follow in this repo.
$ python3 porting-sdk/scripts/root_hygiene.py --port cpp --repo .
[root-hygiene] cpp: clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…ce has
swml::Service::on_swml_request took only (request_data, callback_path). The
Python reference has taken a THIRD parameter, `request`, since it made only the
SECURITY half of the request path request-agnostic and deliberately kept the
DISPATCH hook request-aware:
# web_mixin.py:1347-1352
def on_swml_request(
self,
request_data: dict[str, Any] | None = None,
callback_path: str | None = None,
request: Request | None = None,
) -> dict[str, Any] | None:
"""... request: Optional FastAPI Request object for accessing query
params, headers, etc."""
So a C++ subclass overriding this hook could not reach the inbound request AT
ALL -- query params and headers were simply invisible to the dynamic-config
hook, which is precisely what such a handler needs. That is a CAPABILITY GAP,
not idiom.
The gap had a visible symptom. InfoGathererAgent already needed the request, so
it declared its own 3-parameter `json on_swml_request(const json&, ...)` --
matching neither the base's arity nor its return type. That does not override;
it HIDES. The virtual dispatch at src/swml/service.cpp:596 therefore never
reached it, and every example including prefabs.hpp reported
-Woverloaded-virtual (5 sites in the library).
Changes:
* swml::Service::on_swml_request gains
`const std::optional<json>& request = std::nullopt`. `request` is modelled
as a JSON object carrying `query_params` and `headers` -- the same two
attributes the reference reads off its framework Request object.
* Service::on_request passes std::nullopt for it, exactly as the reference
passes None from that path (web_mixin.py:1342). on_request itself keeps TWO
parameters, matching the oracle, which records three only for
on_swml_request.
* InfoGathererAgent::on_swml_request becomes a genuine `override`:
std::optional<json> return, std::optional<json> params. "No override" is now
std::nullopt where it used to be a null json.
Matches the oracle exactly. python_signatures.json records
(request_data, callback_path, request) for WebMixin.on_swml_request AND
InfoGathererAgent.on_swml_request; cpp now records the same three on WebMixin,
SWMLService and InfoGathererAgent, and InfoGathererAgent's return type folds
from `any` to `optional<any>` to match its base.
BREAKING for any downstream subclass that overrides on_swml_request with the
2-parameter signature: it will now fail to compile as `override` rather than
silently hiding the base. That is the point -- silent hiding is what this fixes.
tests/test_web.cpp's CustomSwmlService was exactly such a case and is updated.
Two new tests cover the capability itself, not just the shape:
web_on_swml_request_receives_the_request_object
asserts query_params/headers actually ARRIVE at an override.
web_on_swml_request_dispatches_virtually_through_a_base_reference
calls through a `Service&` and asserts the derived override runs -- the
dispatch that was broken while the method was hidden.
The existing delegation test additionally asserts on_request supplies NO request
object, pinning the reference's None-from-that-path behaviour.
Verified:
$ cmake --build build -j 8 -> exit 0, 0 errors
$ bash scripts/run-tests.sh -> 2136 passed, 0 failed
(2134 + the 2 new tests)
$ cmake -DCMAKE_CXX_FLAGS=-Wall + build signalwire
-Woverloaded-virtual: 5 -> 0
Regenerated surface artifacts follow in their own commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…wml_request widening
Adding the third parameter to swml::Service::on_swml_request moves PUBLIC
SIGNATURE and SURFACE, so the audited artifacts are regenerated and committed on
their own, per the rollout rule.
port_signatures.json -- on_swml_request now records four params
(self, request_data, callback_path, request) on all three classes that carry it,
and InfoGathererAgent folds to its base types:
WebMixin.on_swml_request + request: optional<any>
SWMLService.on_swml_request + request: optional<any>
InfoGathererAgent.on_swml_request + request: optional<any>
request_data: any -> optional<any>
returns: any -> optional<any>
port_surface.json -- provenance SHA only; the method NAME set is unchanged
(widening a signature does not add or remove a symbol).
port_surface_native.json -- regenerated and byte-identical, which is correct
rather than skipped: it records member SPELLINGS, and no spelling changed.
cpp DOES emit this third artifact, so it was explicitly re-run and re-read
rather than assumed (an empty git diff alone cannot distinguish "no change"
from "never written").
VERIFIED BY READING each regenerated file for the symbol, not by an empty diff:
port_signatures.json
WebMixin: [self, request_data, callback_path, request] ret optional<any>
SWMLService: [self, request_data, callback_path, request] ret optional<any>
InfoGathererAgent: [self, request_data, callback_path, request] ret optional<any>
port_surface_native.json
on_swml_request present in native_names (2530 names)
Gates:
$ python3 porting-sdk/scripts/suites/surface.py --port cpp --repo .
[SURFACE:SIGNATURES] PASS [SURFACE:DRIFT] PASS
[SURFACE:SURFACE-FRESH] PASS [SURFACE:SURFACE-DIFF] PASS
[SURFACE:GEN-TYPE-DEGENERACY] PASS [SURFACE:ROUTE-COLLISION] PASS
[SURFACE:GEN-IDIOM] PASS [SURFACE:SEMVER-DIFF] PASS
[SURFACE] all 8 rules PASS
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…no-overloaded-virtual
-Wall was applied to the EXAMPLE targets only (line 449 was the sole occurrence
in this file), so the shipped library was held to a lower compiler-warning bar
than its own demos. That is backwards: if a warning is worth hearing about in a
tutorial program, it is worth hearing about in the code users link against.
`target_compile_options(signalwire PRIVATE -Wall)`. PRIVATE so consumers of the
installed target do not inherit the flag.
This is now FREE. When first measured the library reported 7 warnings; 5 were
-Woverloaded-virtual from the on_swml_request hiding, which the previous commit
fixed at the signature. The only two left under -Wall are inside the vendored
FetchContent IXWebSocket tree (IXSocketOpenSSL.cpp:291, unused `host`/`pattern`)
-- not our code, and already excluded from lint for the same reason deps/ is.
First-party src/ + include/ is at ZERO.
Also removes the `-Wno-overloaded-virtual` this lane briefly added to the example
targets. It was a holding position while the signature question was open; the
question is answered and the warning has no site left to fire from, so the
suppression goes rather than lingering as dead config. Examples keep
-Wno-unused-variable, which is genuinely example-idiomatic (an example may
declare a value to SHOW the API returns one without going on to use it).
One knock-on: -Wall now also reaches run_tests, which links the library, and
that surfaced 3 -Wunused-result at ASSERT_THROWS sites. Fixed once at the macro
rather than three times at call sites, with a scoped
`_Pragma("GCC diagnostic ignored \"-Wunused-result\"")` around the single
statement. ASSERT_THROWS must emit `expr` as a STATEMENT -- callers pass
declarations such as `ASSERT_THROWS(SessionManager sm(short_secret))`, which
cannot be an argument to a sink -- so discarding a [[nodiscard]] result there is
deliberate, and the pragma now says so to the compiler as well as to clang-tidy
(replacing the NOLINT, which only ever reached the latter).
Verified, from a CLEAN build directory:
$ rm -rf build && cmake -S . -B build && cmake --build build -j 8
exit 0; first-party warnings: 0 (2 remain, both in _deps/)
$ cmake --build build --target examples -j 8
exit 0; first-party warnings: 0
$ bash scripts/run-tests.sh
Total: 2136 Passed: 2136 Failed: 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…oped off
tests/ was the last first-party tree outside LINT. It is in now, held to the
SAME .clang-tidy, the SAME WarningsAsErrors:'*', and burned to ZERO -- with
exactly three checks scoped off, owner-ruled, each because the rule is wrong for
THIS context rather than inconvenient:
performance-unnecessary-copy-initialization
MACRO ARTIFACT and a correctness hazard. 319 of 320 findings come from
`auto _a = (a)` inside ASSERT_EQ/ASSERT_NE, not from test source, and that
copy is LOAD-BEARING: binding by const& makes `mock.requests()[0].method`
a reference into a by-value temporary that dies at the end of the full
expression. Measured -- the "fix" fails 5 tests. A rule whose remedy
introduces dangling references into correct code is mis-scoped here.
readability-simplify-boolean-expr
MACRO ARTIFACT, 231 of 231. ASSERT_TRUE(x) expands to `if (!(x))` and the
check proposes DeMorgan on the MACRO's negation of the caller's condition.
There is nothing in test source to simplify.
bugprone-suspicious-include
THE DOCUMENTED ARCHITECTURE, 123 of 123. CLAUDE.md:96 -- "All test files
are #included into test_main.cpp and compiled as one translation unit."
Everything else was BURNED, not excused. This commit clears the last 22:
* 21 bugprone-unchecked-optional-access -- real crash-instead-of-fail sites
(`*x` / `x.value()` with no engagement check). Fixed by binding the optional
to a reference ONCE and checking that, which is both correct and better
code. Isolated the analyser behaviour first rather than guessing: a plain
`if (!p.sections[0].title.has_value())` followed by `*p.sections[0].title`
fires with NO macro involved, while `auto& t = p.sections[0].title;` then
check-and-deref is clean -- so the blind spot is the VECTOR SUBSCRIPT
breaking the dataflow link, not the ASSERT macros.
* 4 performance-inefficient-string-concatenation in the two mock harnesses.
* 1 bugprone-macro-parentheses on ASSERT_THROWS, suppressed per-line: `expr`
cannot be parenthesised because callers pass declarations, and
`(SessionManager sm(secret))` is not valid.
.clang-tidy's HeaderFilterRegex gains tests/. That is load-bearing, not
cosmetic: the 123 #included test files are not the "main file" of any TU, so
without tests/ in the filter their findings are dropped and the gate would be
VACUOUS over 94% of the tree it claims to cover.
NON-VACUITY PROVEN ON THE REAL GATE, on the hard case -- a file that is NOT a
translation unit:
planted a violation in tests/test_swml.cpp (#included into test_main.cpp):
$ bash scripts/run-lint.sh
EXIT=1
tests/test_swml.cpp:17:7: error: the 'empty' method should be used ...
tests/test_swml.cpp:17:21: error: statement should be inside braces ...
reverted:
$ bash scripts/run-lint.sh
EXIT=0, 0 findings
The finding is attributed to the INCLUDED file at its own line, which is the
thing that proves the coverage is real.
Also adds a guard against the TU list going stale. Only 4 tests/*.cpp are real
TUs; the rest have no compile_commands entry, so handing them to clang-tidy
would ERROR rather than analyse. The gate now fails loudly if any tests/*.cpp is
neither a listed TU nor #included by test_main.cpp -- i.e. if a new test file
would be silently unanalysed. Verified by adding an orphan file: exit 1 naming
it.
$ bash scripts/run-lint.sh -> exit 0, 0 findings (all trees)
$ bash scripts/run-tests.sh -> 2136 passed, 0 failed
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
scripts/run-pylint.sh invoked bare `ruff check scripts/` with no --config. ruff
resolves configuration by walking UP from the TARGET -- not from the CWD, not
from the repo root -- and when that walk finds nothing it falls back to BUILT-IN
DEFAULTS: a different ruleset, reported as success. That is the vacuity trap
this campaign keeps paying for, and two sibling ports measured real drift from
it (one found 0 where its real config found 4; another had 7 findings silently
change status).
cpp was green today only because ruff.toml happens to sit at the repo root and
the target happens to be under it. Both halves of that are incidental.
Reported as latent; MEASURED AS LIVE. From a foreign CWD with an absolute
target -- the shape any CI runner or wrapper can produce:
bare: 0 findings ("All checks passed!")
--config <abs path>: 148 findings
139 of those 148 were the VENDORED scripts/clang_tidy_cache.py, i.e. the
exclusion protecting third-party code had silently stopped applying. The rest
were per-file-ignores anchored to the wrong root.
Three fixes, because pinning alone was not sufficient:
1. Pin the config on every invocation -- check, --fix, format, --check.
2. ERROR when ruff.toml is missing instead of proceeding on defaults. A
silently-defaulted lint gate is worse than no gate. (Copied from the rust
lane's guard.) Verified: exit 1 with an explicit message when the file is
moved away, exit 0 when restored.
3. Make the config's own path rules path-robust. `exclude` and every
per-file-ignores key were RELATIVE ("scripts/<name>.py"), and ruff anchors
those to the config's directory -- so they stopped matching when the target
was spelled absolutely. The per-file-ignores keys are now basename globs
("**/<name>.py"), which match however the path is written. For the vendored
file the runner additionally always passes the DIRECTORY, never the file:
ruff's `exclude` governs directory traversal, and a path named explicitly on
the command line is analysed even when excluded.
Verified CWD-independent, which is the property that was missing:
$ (from repo root) bash scripts/run-pylint.sh --check
All checks passed! / 8 files already formatted / exit 0
$ (from a foreign, working-dir-local CWD) bash .../run-pylint.sh --check
All checks passed! / 8 files already formatted / exit 0
$ (ruff.toml moved away) bash scripts/run-pylint.sh --check
ERROR: .../ruff.toml not found. Refusing to run ... / exit 1
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
ContextBuilder::attach_tool_name_supplier was public API. It should never have
been: the Python reference has NO such method --
`grep -rn "tool_name_supplier\|attach_tool_name" signalwire-python/signalwire/`
returns nothing -- and it has exactly one caller anywhere, an internal one:
// src/agent/agent_base.cpp:897
context_builder_->attach_tool_name_supplier([this]() { return this->list_tools(); });
It is a wiring seam this port needs because C++ cannot reach back to the owning
agent the way Python does, where the agent consults its own tool registry
directly. That is an implementation detail of how AgentBase and ContextBuilder
are joined, not a capability a caller is meant to reach for. Being public made
it invented surface on the audited API.
Now private, with `friend class ::signalwire::agent::AgentBase` -- the only
intended caller. AgentBase is forward-declared rather than included, since
agent_base.hpp already includes contexts.hpp and including it back would be
circular. BEHAVIOUR IS UNCHANGED: the same closure is attached at the same
point, and validate() still rejects a user tool colliding with a reserved
native name.
This also removes the last type-translation failure. The `std::function<
std::vector<std::string>()>` parameter has no vocabulary type, so the enumerator
could not translate it -- and silently dropped the whole symbol from
port_signatures.json rather than failing. Hiding the member is the correct fix
rather than inventing a `callable` alias: cpp does not become the only port
carrying a vocabulary term no other port uses, and no ledger entry is needed.
Combined with the four integer aliases in porting-sdk b3904c6, translation
failures go 5 -> 0.
Removes the now-stale PORT_ADDITIONS.md entry. Its rationale was false anyway --
it described this SETTER as "cpp_typed_accessor: const-reference accessor on the
C++ class; Python exposes equivalent state via attribute reads not enumerated",
which is not what the method is or does. Every entry in that file is a place the
parity checker stops comparing, so a stale one is a permanent blind spot.
Two new tests pin the behaviour through the PUBLIC AgentBase API, so that hiding
the method cannot silently disable the thing it exists for:
contexts_reserved_tool_name_collision_is_rejected_via_agent
contexts_non_reserved_tool_name_passes_validation_via_agent
RED before / GREEN after, proven by unwiring the seam at agent_base.cpp:897:
RED FAIL: threw at tests/test_contexts.cpp:639
contexts_reserved_tool_name_collision_is_rejected_via_agent... FAILED
Total: 3 Passed: 2 Failed: 1
GREEN restored -> Total: 2138 Passed: 2138 Failed: 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…pplier
Making ContextBuilder::attach_tool_name_supplier private removes it from the
audited public surface, so all THREE artifacts are regenerated and committed on
their own.
port_surface.json 1758 -> 1757 methods; ContextBuilder now reads
[__init__, add_context, get_context, has_contexts,
reset, to_dict, to_json, validate]
port_surface_native.json 2530 -> 2529 native names
port_signatures.json the symbol was ALREADY absent here -- that is the
defect this whole thread started from: its
std::function parameter failed to translate and the
enumerator dropped the method silently at exit 0.
The file still changes, because the four integer
aliases from porting-sdk b3904c6 restored three
other methods (1938 -> 1941), including
SWMLService::generate_random_hex.
VERIFIED BY READING each regenerated file for the symbol, not by an empty diff:
port_signatures.json attach_tool_name_supplier -> 0 occurrences
port_surface.json present in ContextBuilder: False
port_surface_native.json present: False (2529 names)
Translation failures are now ZERO, which is what lets the fail-loud default land
green in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…Y DEFAULT
The eighth instance of the silent-success shape in this fleet's parity tooling,
and the first found in a SIGNATURE enumerator rather than a surface one.
enumerate_signatures.py had an opt-in `--strict` that made a type-translation
failure exit 1. NOT ONE of the six gate invocations passed it:
scripts/run-ci.sh
porting-sdk/scripts/suites/_signatures_fresh.py:156, :163
porting-sdk/scripts/suites/_surface_commands.py:448, :481, :535, :562, :589, :635
All call the script bare. So the fail-loud path was DEAD CODE, and the real
behaviour was: print a warning, exit 0, and write an artifact with the affected
symbols MISSING. A dropped symbol is worse than a wrong one -- the SIGNATURES /
DRIFT gates then compare against a surface the port does not actually have, and
report nothing wrong.
Measured on this repo before the fix: 5 translation failures at rc=0, with
ContextBuilder::attach_tool_name_supplier (contexts.hpp) and
SWMLService::generate_random_hex (service.hpp:362) both silently absent from
port_signatures.json despite being declared public API.
`argparse.BooleanOptionalAction, default=True` -- rust's shape, and the same
transplant applied to porting-sdk's own enumerate_python_signatures.py in
645d8ad for the identical defect. This deliberately requires NO change to any of
the six call sites: they simply start failing loud. `--strict` still parses, so
the documented invocation keeps working, and `--no-strict` is the local-only
escape hatch for inspecting a partial artifact.
Lands GREEN because the underlying failures are gone: four integer aliases in
porting-sdk b3904c6 (5 -> 1) and hiding the internal wiring seam in the previous
commit (1 -> 0).
RED before / GREEN after, proven by planting the exact regression class -- a
public member whose type has no vocabulary mapping:
$ python3 scripts/enumerate_signatures.py # what all six gates run
enumerate_signatures: 1 translation failure(s)
enumerate_signatures: REFUSING to write a signature artifact that silently
OMITS the symbols above...
exit 1
$ python3 scripts/enumerate_signatures.py --no-strict
exit 0 # escape hatch still works
probe reverted:
$ python3 scripts/enumerate_signatures.py
wrote port_signatures.json (92 modules, 1941 methods)
exit 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…positives
The LINT gate went red on 13 findings, ALL in tests/ and ALL false. clang-tidy
cannot see through this suite's own assertion macro (test_main.cpp:36):
#define ASSERT_TRUE(x) do { if (!(x)) { ...; return false; } } while (0)
Every test is a `bool` function, so `ASSERT_TRUE(opt.has_value());` genuinely
guards the `opt.value()` that follows — the function has already returned if the
optional was empty. clang-tidy's dataflow model does not treat the macro's
`return false` as narrowing the optional's state.
VERIFIED, NOT ASSUMED. Each of the 13 sites was checked against its enclosing
TEST block: test_agent.cpp:263 (guard at 262), test_config_loader.cpp:57 (56),
and 11 in test_relay_states_mock.cpp (90/89, 104-106/103, 129-130/128,
148-149/147, 158-161/157). ZERO were real unchecked accesses. Library code under
src/ and include/ produced NO findings, so the rule stays fully enforced where it
catches actual bugs — this config is scoped to tests/ only.
Rejected alternatives: `*opt` hides the throw without adding a check;
`if (!opt) return false;` duplicates the assertion and loses the message naming
the expectation; per-line NOLINT on 13 sites is noise every new assertion
re-earns. Per RULES.md §3, a check that fails correct, wire-neutral code is
mis-scoped for that context.
A NEAR-MISS WORTH THE COMMENT IN THE FILE: the first version omitted
InheritParentConfig, and a nested .clang-tidy REPLACES the parent check list
rather than extending it. That left tests/ with "Error: no checks enabled" —
EVERY rule silently off while the config appeared to disable exactly one. Caught
by RUNNING the gate, not by reading the config.
Verified by count, in both directions:
repo root unchecked-optional-access ENABLED (1)
tests/ unchecked-optional-access disabled (0), 227 other checks still on
run-lint.sh exit 0, 0 findings (was 13)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
… surface
porting-sdk 8496c77 made all 18 builtin skill modules visible to the signature
oracle (a class whose every method was a base-identical override used to vanish
along with its module). That surfaced 67 new DRIFT findings on cpp, every one of
them a SkillBase hook on one of 11 `signalwire.skills.*` classes.
None of them was a missing implementation. All 11 skills ARE ported --
`src/skills/builtin/{claude_skills,datasphere,datasphere_serverless,datetime,
google_maps,info_gatherer,joke,math,native_vector_search,swml_transfer,
web_search}.cpp` -- and every hook is either overridden there or inherited from
`signalwire::skills::SkillBase`. libclang simply never saw them: the header walk
does not open `.cpp` implementation files.
THE ENUMERATOR CARRIED A STALE ASSUMPTION, the same one the oracle fix removed.
It projected exactly ONE skill member -- `SpiderSkill.remove_xpaths`, via a
size-1 `_SKILL_ACCESSOR_PROJECTIONS` allowlist -- on the premise that the
SIGNATURE oracle recorded skill subclasses method-LESS. It did, until 14:55
today. Meanwhile `enumerate_surface.py` had projected the full hook surface for
every skill all along, which is why SURFACE-DIFF was green while DRIFT was not:
the two enumerators disagreed about whether skill classes have methods.
The fix generalises the projection rather than widening the allowlist. Each
skill's oracle-recorded SkillBase hooks are emitted with the C++ `SkillBase`'s
OWN walked signature -- read back out of the in-flight inventory, never
hand-written -- so the audit compares the port's genuine shape. Fail-honest four
ways: the skill's `.cpp` must exist and define the class (shared with the surface
projection's `_scan_skill_methods`, one source of truth); the C++ class must
genuinely have the member (own-defined or inherited); the reference oracle must
record it, read LIVE from python_signatures.json; and `SkillBase` must carry the
hook in the walked headers. Anything failing one is dropped, never invented.
Gating on the LIVE signature oracle rather than `SKILL_PROJECTIONS`' hand-kept
`py_methods` lists is load-bearing: those lists are the SURFACE oracle's and are
wider, and trusting them emitted hooks for four skills (api_ninjas_trivia,
play_background_file, weather_api, wikipedia_search) that the signature reference
records with `__init__`/`get_tools`/`search_wiki` only -- 11 `missing-reference`
findings, i.e. invented surface. Measured, then fixed.
DRIFT 67 -> 31, by set-difference with `--omissions` on both sides:
RESOLVED 36, NEW 0, PERSISTED 31
excused divergences 1024 -> 1024 (did NOT rise to absorb anything)
PORT_SIGNATURE_OMISSIONS / PORT_OMISSIONS / PORT_ADDITIONS entry counts
unchanged at 233 / 34 / 369 -- no ledger entry added
negative control: reverting this file alone regenerates 92 modules / 1941
methods and reproduces exactly 67
THE 31 THAT REMAIN ARE A REAL C++ DIVERGENCE, NOT AUDIT NOISE, and they are now
reported as what they are. They were `missing-port` (the method does not exist);
they are now `param-count-mismatch` / `return-mismatch` (the method exists, its
shape differs). Three hooks, 11 classes:
setup 11x param-count-mismatch reference `setup(self)` reads
`self.params`; C++ `setup(const json& params)` takes
them at attach time
register_tools 11x return-mismatch reference returns void and
calls back via `self.define_tool(...)`; C++ RETURNS
`vector<ToolDefinition>`
get_prompt_sections 9x return-mismatch reference returns `list[dict]`;
C++ returns `vector<SkillPromptSection>`
All three are the SAME divergence already recorded once, audited, on the base:
`signalwire.core.skill_base.SkillBase.{setup,register_tools,get_prompt_sections}`
(`cpp_typed_overload_subset` / `cpp_typed_skill_pipeline`). The concrete skills'
copies are that base virtual seen one level down. PHP -- which absorbed the same
oracle change with zero findings -- implements the reference shapes literally
(`setup(self) -> bool`, `register_tools() -> void`), so this is C++'s design
choice, not an artifact. Closing it is either an owner ruling that the base
divergence covers its inherited copies, or an SDK change to the skill pipeline;
NO omission entry is added here either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
…LE is set
SECURITY (#90, the silently-plain-transport shape). Setting
`SIGNALWIRE_RELAY_CA_FILE` is an explicit request to VERIFY the RELAY peer
against that CA — which a plaintext transport can never honour. Until now, if
the transport ALSO resolved to plain `ws://` (`SIGNALWIRE_RELAY_SCHEME=ws`),
`RelayClient::open_ws_transport()` took the `connect_plain()` branch without ever
looking at the CA variable: the client completed a full unencrypted WebSocket
session, authenticated over it with the project id and API token in the clear,
and reported success. A caller who asked for encryption got none and was never
told.
The two settings are independently reachable, which is what makes this a live
footgun rather than a theoretical one: `SIGNALWIRE_RELAY_SCHEME=ws` is exported
by the audit fixture and by every port's own mock harness, so a leaked export, a
stale shell, or a CI job that sets one and not the other is enough.
`open_ws_transport()` now refuses that combination and returns false, logging
which setting would otherwise have been silently ignored. It is the shared
connect/reconnect funnel, so the guard covers reconnection too — the path that
would otherwise re-establish the plaintext session after a drop.
PLAINTEXT WITHOUT THE CA VAR IS UNCHANGED. `SIGNALWIRE_RELAY_SCHEME=ws` on its
own is an unambiguous request for a clear connection (the audit fixture, dev
servers) and still connects exactly as before. Only the contradictory pair is
refused. All 278 relay tests stay green.
signalwire-rust already ships this guard (`src/relay/client.rs`, "NO SILENT
DOWNGRADE"); this brings C++ to the same behaviour.
Proven behaviourally, not by inspection —
tests/test_tls_relay_no_downgrade.cpp drives the real RelayClient against the
real plain-ws mock:
RED (before this fix, src/relay/client.cpp unchanged):
FAIL: !(ok) at tests/test_tls_relay_no_downgrade.cpp:81
Total: 1 Passed: 0 Failed: 1
GREEN (after):
tls_relay_ca_file_refuses_plaintext_downgrade... OK
Total: 1 Passed: 1 Failed: 0
The case carries its own control: the SAME plaintext connect with the CA var
UNSET must still succeed, so it cannot pass merely because the mock is
unreachable, and the refusal is pinned to the CA-var condition rather than to
plaintext in general.
The rest of the C++ TLS surface was probed the same way and is CLEAN — a
standalone probe drove the shipped `signalwire::rest::HttpClient` against a real
`httplib::SSLServer` with a self-signed cert:
A default verify REJECTS untrusted self-signed PASS
B SIGNALWIRE_REST_CA_FILE trusts that CA PASS
C set_ca_cert_path() trusts that CA PASS
E wrong CA file still REJECTS (no silent fallback) PASS
D https:// never downgrades to plain HTTP PASS
Negative-controlled: a raw `httplib::Client` with
`enable_server_certificate_verification(false)` gets HTTP 200 from that same
untrusted server while the default-verify client is refused — so check A
discriminates and is not vacuous. REST has no env var that can flip its scheme
(only the two CA-bundle vars), and `RestClient` defaults to `https://`; `http://`
is reachable only through an explicit `with_base_url()` call. There is no
`enable_server_certificate_verification(false)` / `verify_none` /
`InsecureSkipVerify` anywhere in src, include, tools, examples, rest or relay.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
The three SkillBase hooks (setup, register_tools, get_prompt_sections) diverge from the reference on SkillBase ITSELF — one C++ design decision, already described exactly once in PORT_SIGNATURE_OMISSIONS. Projecting that true C++ shape onto all 18 concrete skills made the audit re-report the same single fact 31 more times. Fold: when a concrete skill's override is signature-identical to the hook SkillBase declares, it contributes no divergence of its own, so the projected member is emitted in the REFERENCE's shape. The base keeps the single recorded description; the subclass compares equal and stays under active comparison. Gated three ways on live sources, never a hand-kept list: normalized declaration-text comparison against the real .cpp and skill_base.hpp; a runtime read of PORT_SIGNATURE_OMISSIONS so the fold cannot outlive the entry justifying it; plus the pre-existing oracle gate. Scoped to the 3 divergent hooks — the 5 already-matching hooks are excluded, and SkillBase itself is deliberately not folded so its two entries stay load-bearing. drift 31 -> 0 (OPENED=0, CLOSED=31), excused flat at 1024, port symbol set byte-identical, ledger unchanged at 233/34/369. Zero ledger entries added. Negative-controlled: perturbing JokeSkill::setup to take an extra param in the real .cpp reds the gate with exactly 1 drift while the other 17 stay folded; removing the base ledger entry drops that hook from the foldable set. Also corrects CLAUDE.md — CPPHTTPLIB_OPENSSL_SUPPORT is ENABLED at CMakeLists.txt:116, not disabled. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TSHZoWoxoPVK6FuNaNKWFh
… oracle
Re-drift against porting-sdk oracle 0e0f935, which both cpp reds trace to.
THE REGEN WAS ALWAYS DETERMINISTIC. The prior turn's SIGNATURES-FRESH red was
diagnosed as "uncommitted-vs-HEAD" and, when that proved wrong, as a
non-idempotent fold. Neither is right: running enumerate_signatures.py twice from
clean yields byte-identical output (sha dd54df26), as does enumerate_surface.py
(sha 8817ab53). The enumerator is a pure function of sources + oracle + ledger.
What actually changed was the ORACLE, 11 minutes after the artifact was generated:
17:27 port_signatures.json generated (old oracle)
17:38 porting-sdk 0e0f935 "regenerate after the skip_prompt guard fix"
The reference made SkillBase.get_prompt_sections() a final template method that
applies the skip_prompt guard and delegates to a PROTECTED _get_prompt_sections()
hook (signalwire-python core/skill_base.py:89-96), so 13 skills now override the
protected hook and the PUBLIC member exists on the base only. The oracle dropped
it from 11 skills on the signature axis and 11 on the surface axis. The committed
artifact predated that, so a fresh regen legitimately differed — the artifact was
stale, not the generator unstable.
SIGNATURE AXIS: no code change needed. The hook projection is already oracle-gated
("the reference oracle must record that member"), so it dropped the member on its
own. Only the artifact needed regenerating.
SURFACE AXIS: a real defect, fixed at the emitter. _project_builtin_skills trusted
SKILL_PROJECTIONS' HAND-KEPT per-class py_methods lists, which still named
get_prompt_sections after the reference stopped exposing it — emitting 10 phantom
missing-reference additions. The hand list is now an UPPER BOUND intersected with
what python_surface.json LIVE records for that class, mirroring the signature
enumerator's discipline: emit a member only when the C++ class genuinely has it AND
the reference genuinely records it. A future reference move now self-corrects on
the next regen with no hand edit. Fail-safe: an unresolvable oracle falls back to
the hand list rather than emitting an empty class surface (a mass false deletion).
DEAD ENTRY DELETED (required, not optional):
PORT_OMISSIONS.md MCPGatewaySkill.get_prompt_sections
Confirmed absent from python_surface.json, so it excused nothing. The other six
MCPGatewaySkill entries stay — the Python-only ruling (§I.1) is untouched.
Measured, --omissions + --surface-omissions + --surface-additions on both sides:
signature drift 0 -> 0 (1620 ref / 2018 port symbols)
surface drift 11 -> 0 (10 additions + 1 dead omission)
excused (sig) 1024 -> 1023 FELL by the deleted entry; absorbed nothing
PORT_OMISSIONS 34 -> 33 (one DELETION; zero insertions)
PORT_SIGNATURE_OMISSIONS / PORT_ADDITIONS 233 / 369 unchanged
Negative controls, both arms:
- Genuine divergence still surfaces: perturbing JokeSkill::setup in the real
.cpp to take an extra param re-reds DRIFT with exactly 1 finding while the
other 17 skills stay folded. Reverted; re-verified green.
- The surface gate is oracle-driven, not list-driven: dropping get_hints from a
temp oracle copy stops it being projected with NO edit to SKILL_PROJECTIONS;
and an unresolvable oracle falls back to the hand list, never an empty class.
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 headers. 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 members
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 optional<json> -> optional<string>, and `mcp_error` is a boolean
const true present only when the tool returned no result (actions.c:2162) so
optional<string> -> optional<bool>. 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 ->
optional<string>, hold timeout integer -> ["number","string"]
(optional<int> -> optional<double>), playback_bg file -> optional<string>,
transfer dest -> optional<string>. Its extractor also emits each action
object's property keys ALPHABETICALLY, hence ContextSwitchAction's member
order.
Net effect on the surface is a type tightening plus the three dropped members;
DRIFT stays clean against the Python oracle. port_surface_native.json drops
`step` / `step_index` to match (`context` is still carried by another symbol, so
that name-keyed entry stays). port_signatures.json needs NO change: these DTOs
are method-less structs and cpp's signature oracle does not record the
signalwire.core.post_prompt_generated classes at all, so there were no
synthesized accessors to drop -- SIGNATURES-FRESH was already green.
Verification (bash scripts/run-ci.sh: `==> CI PASS`, every gate green):
[GEN:GEN-FRESH-SWAIG] ... PASS (GEN suite: all 5 rules PASS)
[SURFACE:DRIFT] ... PASS (SURFACE suite: all 8 rules PASS)
[SIGNATURES-FRESH] committed port_signatures.json matches a fresh regen ... PASS
run-tests.sh: Total: 2139 Passed: 2139 Failed: 0
run-format.sh --check (clang-format): clean, no-op on the regenerated headers
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i9TibArqEmDszG7VYEGYg
…clang tools
This repo's native linters are exemplary: .github/workflows/{test,nightly}.yml
install `clang-format==18.1.8` + `clang-tidy==18.1.8` exactly, and scripts/_env.sh
FAILS LOUD unless the local clang-format is major 18, with the reason spelled out
("a different major reformats differently and breaks the FMT / GEN-FRESH gates").
ruff sat on the very next line of the same install step, UNBOUNDED:
.github/workflows/test.yml:107 pip install ruff -> ruff==0.15.21
.github/workflows/nightly.yml:157 pip install ruff -> ruff==0.15.21
The clang-format rationale applies verbatim to ruff: CI resolves the newest
release at run time while a local dev runs whatever they installed months ago, so
a ruff release that adds a rule or changes a format heuristic reds PY-LINT on code
that never changed. 0.15.21 is the fleet-wide ruff (python/perl/php/typescript/
java pin the same).
Both halves, so this is a real local==CI guarantee and not just a CI-side hope:
* each workflow install now ASSERTS the resolved version afterwards (pip can
satisfy a spec from an unexpected index or cache — a pin that silently did not
take is worse than no pin);
* scripts/_env.sh declares SW_RUFF_VERSION;
* scripts/run-pylint.sh asserts it and exits 1 on a mismatch, mirroring the
clang-format major-18 assertion this repo already had.
SW_ALLOW_TOOL_VERSION_DRIFT=1 downgrades the local assertion to a warning, for a
deliberate bump-and-reformat run (then _env.sh and both workflows move together).
Verified: run-pylint.sh --check exit 0 ("All checks passed!", "8 files already
formatted") under the pinned 0.15.21 — 0 new findings from the version change;
run-ci's [PY-LINT] gate PASS.
Negative control: forcing SW_RUFF_VERSION=9.9.9 makes run-pylint.sh --check exit
1 on the mismatch; restoring the pin exits 0.
Not from this change: run-ci reports CI FAIL (gates: SURFACE SIGNATURES-FRESH) on
this branch — SURFACE-DIFF wants SwaigAction/SwaigResponse, and
SIGNATURES-FRESH has 6 stale leaves under post_prompt_generated. Both are wave6
regen debt; a workflow/scripts-only diff cannot reach either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016i9TibArqEmDszG7VYEGYg
… reached through PR #55 was `==> CI FAIL (gates: SURFACE SIGNATURES-FRESH )`. Four unexcused-missing symbols, one generator defect and one stale artifact behind them. generate_swaig_payloads.py:145 read `spec["components"]["schemas"]["SwaigAction"]["properties"]` — reaching THROUGH the envelope schema to lift each action verb's inline object into a named <Verb>Action struct, and never emitting the two schemas swaig-response.yaml actually declares: SwaigAction (the action object; one or more verb keys set at once) and SwaigResponse (the {response, action, post_process} body a handler returns). The docstring's "4 structs … == the surface oracle EXACTLY (0 missing / 0 extra)" had been false since porting-sdk 4ddda70 added them; the arithmetic is now 2 + 14 + 6 = 22. They belong in THIS module because it owns swaig-response.yaml, which is what makes post-prompt.yaml's cross-file `swaig-response.yaml#/components/schemas/SwaigResponse` refs resolvable — the same reason the reference hosts them here (CROSS_FILE_MODULES in generate_python_rest_types.py). Emitted from the spec's own schemas, so the envelope and the per-verb structs cannot drift. The other two missing symbols — PostPromptSwaigLogEntry.post_response / .delayed_post_response — were NOT a port gap: both fields have been in post_prompt_swaig_log_entry.hpp all along. The committed port_surface.json / port_signatures.json were stale, generated before 4ddda70 taught the reference to resolve those cross-file refs, so the enumerator's oracle-intersected projection had nothing to match. A regen alone recovers them. Surface artifacts regenerated in the order the enumerators require — enumerate_signatures FIRST, then enumerate_surface: the surface pass imports composition members by READING port_signatures.json off disk, so the reverse order silently produces a port_surface.json missing the new PostPromptSwaigLogEntry leaves while both commands exit 0. port_surface_native.json picked up SwaigAction's four class-typed verb fields at the same time. Negative control: with the two envelope headers removed, diff_port_surface reports exactly `✗ 2 Python symbol(s) missing from port`; with them present, `✓ port matches Python reference (2634 symbols; 36 excused omissions, 397 excused additions)`. No omission or allowlist entry was added — the missing surface was generated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
… 2026-07-30
Nightly run 30908553939 was `==> CI FAIL (gates: SNIPPET-COMPILE )` on two
docs/swml_service_guide.md snippets, both the same error:
error: 'std::optional<json> VipVoiceService::on_swml_request(
const std::optional<json>&, const std::optional<std::string>&)'
marked 'override', but does not override
swml::Service::on_swml_request gained a THIRD parameter on 2026-07-30
(include/signalwire/swml/service.hpp:391) — `const std::optional<json>& request`,
added so a C++ subclass overriding the dispatch hook could reach the inbound
request at all (query params and headers were previously invisible to it). The
in-tree override in prefabs.hpp:56 was updated then; these two doc snippets were
not, so each declared a 2-arg method that HIDES the base overload instead of
overriding it — precisely the bug prefabs.hpp:50 documents having already been
fixed once. `override` is what turned a silent dispatch failure into a compile
error, which is the whole point of the keyword and of this gate.
The prose API list at line 525 carried the same stale 2-arg spelling; corrected
alongside so the guide does not contradict itself. Swept docs/, examples/ and
README.md — no other occurrence is stale (architecture.md and api_reference.md
mention the hook without spelling its parameters).
The snippets are the only thing wrong here; the signature is right. Fixed the
docs, not the SDK — and not by adding a `no-compile` marker or a
SNIPPET_COMPILE_ALLOW.md entry, either of which would have hidden a genuinely
broken documented override behind a green gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
…wn workflows
Two independent CI wall-clock wins, both measured from runs that already happened
plus local before/after. No tier boundary is touched, no gate does less work.
1) LINT: 12m45s -> 14s, from WHICH cache got restored
-----------------------------------------------------
Same gate, same branch, one day apart, both nightly:
run 30908553939 (2026-08-04) restored ctcache-cpp-Linux-30465863814, written
2026-07-29 -- SIX DAYS stale -- and LINT took 12m45s (12:35:14 -> 12:47:59).
run 30990423636 (2026-08-05) restored a 1-day-old store: LINT took 14s.
The cause is NOT ref isolation. The stale entry is on refs/heads/wave6/ctor-dunder-fold
-- the run's OWN branch -- and a refs/heads/main entry written 3h earlier that morning
WAS visible to it and was not chosen. Actions resolves restore-keys by SCOPE FIRST and
recency second: an own-ref match wins over a newer default-branch one, so a long-lived
branch pins itself to whatever it wrote long ago and never advances.
That means the obvious fix -- "fall back to a shared prefix" -- was already in place
(restore-keys: ctcache-cpp-<os>-) and is exactly what failed; more bare-prefix
fallbacks cannot help, because the own-ref scope is consulted before all of them. So
make the stale entry UNMATCHABLE by the first-choice key: a UTC date stamp tier ahead
of the bare prefix, on both the save key and the restore-keys. Runs on the same day
agree on a key an older branch entry cannot satisfy, and fall through to the undated
prefix only when no recent store exists -- i.e. the worst case is precisely the old
behaviour. ctcache is content-addressed per TU, so restoring an older store was always
CORRECT, just slower; this changes which store is preferred, never the findings.
2) ccache was declared but never installed on the runners that matter
---------------------------------------------------------------------
"note: ccache not found -- C++ rebuilds will be uncached" printed on EVERY runner
(30907242192, 30920814722, 30893413399, 30990423636), so every build was cold. ccache
is declared in porting-sdk's cross-port.yml, but cpp's own test.yml/nightly.yml -- what
actually runs PR + nightly CI -- installed it nowhere. It was wired in 51654ff and
removed in 1fe7b2c as "dead weight" because it cannot accelerate LINT. True of LINT,
but it overshot: these jobs also run two real compilations, and PACKAGE-SMOKE is the
nightly's single largest gate at 13m07s (30990423636, 08:53:51 -> 09:06:58) doing a
full cold Release build+install of the whole library.
Measured locally (8 cores, 130 TUs, the PACKAGE-SMOKE Release shape), via the real
CMake build system:
cold 344.7s (0/130 hits)
warm, same build path 3.0s 130/130 DIRECT hits -> 114x
warm, different path 32.0s 89/260, preprocessed -> 10.8x
Which figure applies depends on build-path stability, and the two consumers differ:
the TEST gate builds in a stable build/ (direct-hit shape), while package_smoke.py
builds in a PID-unique sandbox (preprocessed-only shape). Do not quote 114x for
PACKAGE-SMOKE.
CMakeLists.txt already availability-gates ccache as CMAKE_{C,CXX}_COMPILER_LAUNCHER,
so its presence needs no build-system change and its absence stays a strict no-op.
scripts/_env.sh's comment claimed CI declared ccache; only cross-port.yml did. Both
layers now do, per AGENT_RULES section 7.
Negative control (the gate must still catch what it caught): injecting a
bugprone-use-after-move into src/signalwire.cpp with these changes in place fails LINT
with "error: 'a' used after it was moved [bugprone-use-after-move,-warnings-as-errors]";
removing the probe returns exit 0 with zero findings.
Local verification: scripts/run-ci.sh -> "==> CI PASS", 27 gates PASS/SKIP, 0 FAIL,
including [LINT] clang-tidy curated set, zero findings ... PASS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
… in CI Follow-up to e8ea94b. That commit installed ccache and persisted it, but two things kept the caches from paying off, and both made LOCAL and CI disagree. 1) ccache could never hit its fast "direct" mode ------------------------------------------------ Direct mode keys on the TU's ABSOLUTE path, and PACKAGE-SMOKE -- the nightly's largest gate at 13m07s, one full cold Release build+install of the whole library -- builds in a PID-UNIQUE sandbox (porting-sdk package_smoke.py: .sw-tmp/package-smoke-cpp-<pid>). The path therefore differs on EVERY run BY CONSTRUCTION, so direct mode could never hit and we fell back to preprocessed mode. Fixed by taking the PID out of the cache SIGNATURE rather than out of the path: base_dir rewrites absolute paths beneath it to relative before hashing, and hash_dir=false keeps the cwd out of the hash. The sandbox's isolation is untouched -- that PID is load-bearing (package_smoke.py rm -rf's its own subtree; a shared name would let concurrent runs delete each other's), and it is PSDK-side across ten ports, so changing it there would have been both riskier and wider. Measured end-to-end through two REAL PID-shaped sandboxes: pid 11111 137.5s (cold) pid 22222 1.7s 130/130 DIRECT hits against 32.0s / 89-of-260 preprocessed-only hits before. 2) The caches behaved differently for a local dev than for CI ------------------------------------------------------------- Both cache switches were exported by the workflows and nowhere else: * ctcache activates only when $CTCACHE_DIR is set (run-lint.sh), so a local run-lint/run-ci ALWAYS re-ran clang-tidy from scratch -- 161.3s local versus 14-27s in CI. * keying ccache's basedir off $GITHUB_WORKSPACE would apply on a runner only, silently leaving local devs on preprocessed-only hits. So both now default in scripts/_env.sh -- the shared, CWD-independent bootstrap that run-ci.sh and the canonical run-{format,lint,tests}.sh all source -- and the workflows defer to it. Explicit values still win. Local LINT, measured: 161.3s cold -> 21.9s warm (7.4x), a win that previously existed only in CI. The basedir is DETECTED, not assumed. Using $REPO would bake in "every build dir lives inside the port repo", which is a layout assumption rather than a fact: package_smoke.py scratches under <repo>/.sw-tmp while ca_var_parity.py roots its scratch under the PORTING-SDK checkout instead. So it resolves the deepest COMMON ANCESTOR of this repo and the porting-sdk checkout (honouring $PORTING_SDK, else a sibling), covering scratch under either, and falls back to $REPO when porting-sdk is not visible. No directory name or nesting depth is hardcoded. Verified in both shapes: siblings under ~/src -> /Users/.../src, and a $GITHUB_WORKSPACE-style workspace -> the workspace root. Correctness negative-controlled, no false hits: a changed source recompiles, a changed HEADER recompiles, and a build from a different cwd reuses the right object with __FILE__ intact. With the ctcache warm, injecting a bugprone-use-after-move still fails LINT ("error: 'a' used after it was moved") and reverting returns exit 0 with zero findings -- the cache invalidates on the edit rather than replaying a stale pass. Local verification: scripts/run-ci.sh -> "==> CI PASS", 27 gates PASS/SKIP, 0 FAIL. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
…acle
`_project_gen_payload_getters` defined the port's generated-payload surface as
a SUBSET of the reference oracle's:
oracle_getters = {m for m in ref_cls.get("methods", {}) if m != "__init__"}
present = [f for f in fields if f in oracle_getters]
That is a permanent blind spot in the additive direction. Negative control:
adding a bogus `totally_invented_field` to `ai_params.hpp` left the enumerated
surface completely unchanged, because the oracle does not record that name — so
a field the port implements and the reference lacks could never drift. It also
made the projection silently TRACK the oracle: when porting-sdk e432177 widened
AIParams 60 -> 87, this function's output followed with no change to the port
and no gate ever verifying the port. Deleting a real field was still caught, so
the rule failed in exactly one direction.
Project every field these structs declare instead. The field set is
spec-derived, not oracle-mirrored: each header is generator output from its
authoritative spec (schema.json $defs for swml_verbs, the swaig/post-prompt
component schemas, the RELAY protocol spec), every file is marked DO NOT EDIT,
and GEN-FRESH/-SWML/-RELAY/-SWAIG byte-compare the committed tree to a fresh
regen. A field is present IFF the spec declares it — a stronger guarantee than
"the oracle also lists it", and what makes the full set safe under RULES §3.
The per-CLASS oracle gate is retained deliberately: a payload class the
reference has no counterpart for at all (the 123 relay.protocol_types_generated
structs, whose Python module does not exist) stays unprojected rather than
becoming unmatchable surface. That is module scope, not a field filter, and it
hides no drift within a class both sides have.
port_signatures.json regenerated: AIParams 61 -> 88 members (87 spec fields +
__init__), 2687 -> 2695 methods total. The +8 are wire keys that are Python
reserved words, which the reference drops and cpp carries correctly with
`// wire key:` renames — all verified present in the pinned schema.json $defs:
CondReg.else, CondElse.else, Return.return, ConnectConfig/ConnectDevice{Single,
Serial,Parallel,SerialParallel}.from, PayPrompts.for, Pronounce.with.
The port itself needed no change: cpp's 87 ai_params.hpp fields are an exact
set match to the widened oracle's 87, in both directions.
Coordinated-With: porting-sdk@wave6/ctor-dunder-fold
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MjEro9sSs5TLq66rTzSq6Z
Repoints cpp's RELAY-protocol generator off the legacy
`porting-sdk/relay-protocol/` directory onto the single document
`porting-sdk/combined-specs/relay.yaml`, 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.
Output is unchanged, per phase, at an exact bound:
params 62 structs -> 62 (0) 318 properties -> 318 (0)
result 61 structs -> 61 (0) 280 properties -> 280 (0)
total 123 structs -> 123 (0) 598 properties -> 598 (0)
All 123 emitted headers are byte-identical with NO provenance exception: cpp's
emitted header names the producing script, not the input directory. GEN-FRESH is
green and was negative-controlled -- appending a line to
`calling_play_params.hpp` reported exactly that one file stale.
cpp carried this row's negative control: deleting
`methods['calling.play'].request.params_dto` from an isolated copy of the input
took params 62->61 structs and 318->313 properties (exactly calling.play's 5),
dropped only `calling_play_params.hpp`, and left result at 61/280; restoring
reproduced the baseline byte for byte. Sabotaging the shared adapter itself
moves this port to 116/560 (dropping the unattached merge) or 123/638 (resolving
both phases via the params block), so the control is not vacuous.
The docstring's "126 params/result files - 3 placeholders" arithmetic was stale;
restated as 64 params less 2 = 62, 64 result less 3 = 61.
The combined document omits the `type: object` the per-file envelope declared;
`is_object_schema`'s `(type is None and properties)` branch covers it.
`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.
Retires the
PORT_SIGNATURE_OMISSIONS.mdctor entries that the shared-diffctor/dunder fold makes unreachable. Ledger + nothing else — no port source changed.
The rule is
ALLOWLIST_DISCIPLINE.md:495:__init__-as-a-member is EMISSION(exclude), never a surface capability difference. The fold lives in
diff_port_signatures.py::_is_folded_dunder_member(porting-sdk #125) and excludes an__init__finding only while the class is in the reference'sconstructionnode —a property of the reference, so it cannot drift.
Counts
PORT_SIGNATURE_OMISSIONS.mdentriesPORT_SIGNATURE_OMISSIONS.mdlinesPORT_OMISSIONS.mdPORT_ADDITIONS.mdAll 59 removed entries are
<class>.__init__where<class>is in the oracle'sconstructionnode. cpp had no non-__init__dunder entries.Beyond the 59 entry lines, the diff also drops 19 lines of now-dead prose: the three
rationale-tag definitions that documented only deleted entries
(
cpp_constructor_default_only,cpp_questions_string,cpp_rest_error_field_layout—each now cited by zero entries) and the
### __init__ default-only / config-struct constructionsection header, whose entire contents were dead. No surviving entryreferences any of them.
Excused-divergence delta — the fold moves it, the prune does not
Two separate measurements, per the campaign's corrected expectation
(
_is_folded_dunder_membercontinues before the excusal branch, so a folded ctoris not excused — it is not compared at all):
Flat excused across the prune is the correct signature of dead-weight removal.
Mutual dependency, proven live
The pre-fold differ run against this PR's pruned ledger:
That is the evidence for the merge-order line below, not an assertion.
Construction node unchanged
__init__-as-a-member and the §10 construction-param contract are different contracts;this prune touches only the former.
port_signatures.jsonwas re-enumerated (python3 scripts/enumerate_signatures.py --out port_signatures.json, exit 0, mtime changed, "wrote port_signatures.json (91 modules,1928 methods)") and came back byte-identical, so it is not in this diff.
__init__entries the rule does NOT cover — 3 keptThe guard keeps these LIVE, and they are load-bearing:
signalwire.rest._base.CrudResource.__init__signalwire.rest._base.CrudWithAddresses.__init__signalwire.rest._base.ReadResource.__init__None of the three is in the oracle's
constructionnode, and the reference records no__init__member on them either — but the C++ port does emit one, so each is a realextra-portfinding. Removing them reds the already-folded differ:This is the fold's guard doing exactly its job — it cannot trade a visible ledger entry
for a blind spot.
Gate output
bash scripts/run-ci.sh(full, not--rules) — exit 0,==> CI PASS: 18 gates PASS,0 FAIL, 6 SKIP (all
tier=nightly, deferred to nightly CI by design).BEHAVIORAL(which carriesBEHAVIORAL-WIRE-RELAY) passed on the first run — nostdout-corruption re-run was needed despite several heavy lanes competing for CPU.
The
_restore_treetrap is a non-issue here: the post-prune re-enumeration produced abyte-identical
port_signatures.json, and the working tree afterrun-ci.shstill showsonly the one intended markdown modification.
Pre-flight, both clean:
DRIFT via the real gate path (
scripts/drift.sh, which is what_surface_commands.pyinvokes; cpp has no
.drift-numeric-monotypemarker):Coordinated-With: porting-sdk@wave6/ctor-dunder-fold