wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold) - #55
Open
mjerris wants to merge 26 commits into
Open
wave6: retire dead ctor entries (ALLOWLIST_DISCIPLINE §495, shared-diff fold)#55mjerris wants to merge 26 commits into
mjerris wants to merge 26 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
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):