Skip to content

fix(ari)!: externalMedia hands back a channel id that finds its stream - #305

Merged
Harol-Reina merged 6 commits into
mainfrom
Harol-Reina/externalmedia-channel-id
Sep 24, 2026
Merged

Harol-Reina merged 6 commits into
mainfrom
Harol-Reina/externalmedia-channel-id

Conversation

@Harol-Reina

@Harol-Reina Harol-Reina commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

ExternalMediaActivity accepts an AudioSocketServer and then polls GetStream(Channel.Id). It could never hit — by any configuration. Measured against a real Asterisk 22.9.0 and 23.4.1, which behave identically.

what the activity sends result
its defaults 200 → a UnicastRTP channel nothing connects to; the poll waits out 30 s
Encapsulation = "audiosocket" 400 transport must be 'tcp' for audiosocket encapsulation
…plus the transport 400 data can not be empty

ADR-0060 predicted a timeout and recorded the residual as "tracked separately". Nothing tracked it. This change is that tracking, and the harvest the #302 close-out owed.

The two identifiers were different parameters

A probe with two distinct, byte-order-asymmetric UUIDs settles which one travels, and in which order:

channelId = 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9
data      = f9e8d7c6-b5a4-3928-1706-f5e4d3c2b1a0

channel.id : 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9
HEX        : 01 00 10 f9 e8 d7 c6 b5 a4 39 28 17 06 f5 e4 d3 c2 b1 a0
UUID on the wire == channelId ? False    == data ? True

data becomes the identification UUID the stream table is keyed by; channelId becomes the ARI Channel.Id. One value in both makes Channel.Id be the key.

What changed

  • CreateExternalMediaAsync gains channelId, appended rather than placed first — deliberately. Every slot from encapsulation to data is string?, so inserting ahead of them would have rebound each positional argument one place over and gone on compiling. Appending produces CS1503, which is loud.
  • The activity derives its request from the server it was handed, and mints one canonical-lowercase UUID into both parameters. The lowercase is load-bearing: the table is an ordinal dictionary, and an uppercase identifier creates a channel whose stream cannot be found — measured, HTTP 200 and a miss.
  • An AudioSocketServer against a non-AudioSocket encapsulation is now refused, not left to time out.
  • The contract text says what the key is and in what form. "Get an active stream by channel ID" sat on the interface and, verbatim, on both shipped implementations.

Rejected alternative, recorded

Passing the identifier only as data fixes the activity with zero API break — and leaves a consumer holding only an ARI channel id unable to find the stream, which is the capability itself. The break is a purchase, not an oversight.

Verification

build            0 Warning(s), 0 Error(s)
unit lane        3643 passed, 0 failed
Governance       129 passed, 0 failed
openspec         15 passed, 0 failed  (--all --strict)
dotnet pack      exit 0, 29 packages
pack neg. ctl    suppressions removed -> exit 1, exactly CP0002 + CP0006
coverage         band 84.37%, patch 100.0% (22/22), exclusions 0
functional       new test passes on Asterisk 22.9.0 AND 23.4.1

The functional test is controlled by reverting the fix three ways, not by breaking its assertion. The third reversion first read Asterisk did not connect to audio server — a sentence that is false about what happened and indistinguishable from Asterisk never connecting. It now prints the returned channel id beside the server's own table.

The pack negative control is not ceremony: PackageValidation is incremental, and with the suppression file deleted a repeat dotnet pack still exits 0 and prints 29 successful packages.

Residuals are harvested into openspec/changes/a-published-surface-is-one-something-measures — with a link, which is what ADR-0060 did not do.

🤖 Generated with Claude Code

…-its-stream

ADR-0060 recorded that correcting the AudioSocket wire format does not make the
ARI route work end to end, and said both residuals were "tracked separately".
Nothing tracked them. This change is that tracking, and the harvest the #302
close-out owed.

The route is broken in two places, both measured against a real Asterisk 22.9.0
and captured in probe-capture.txt:

  - What ExternalMediaActivity sends today returns HTTP 400 "data can not be
    empty". The earlier note predicted a timeout; the request never gets that
    far. data is mandatory for encapsulation=audiosocket.
  - data becomes the identification UUID the stream table is keyed by, and
    channelId becomes the ARI Channel.Id. They are different parameters. The
    probe used distinct byte-order-asymmetric UUIDs so the capture says both
    which one travels and in which byte order.
  - Setting both to one UUID makes Channel.Id equal the wire UUID and the
    lookup hit. Reproduced twice with fresh UUIDs.
  - CreateExternalMediaAsync exposes data but not channelId, so a caller cannot
    align them even knowing how.

No test caught it because all four constructions of ExternalMediaActivity use
the one-argument overload, leaving both server fields null so the polling block
never runs — the same shape as the wire-format defect one level up.

tasks.md carries three follow-ups this change does not fix, with links rather
than the words "tracked separately": the WebSocketAudioServer URL-segment key,
AudioStreamMetrics' ten uncalled instruments, and the five extensions that
[test-functional] is dialed at but never defines.

openspec validate --all --strict: 14 passed, 0 failed.
Governance tests: 129 passed, 0 failed.
… broke it

A five-agent review returned ship-with-changes with two blocking findings. Both
were against the proposal's own evidence, and verifying them found a third thing
neither the proposal nor the review had right.

1. The capture's RUN D was labelled "exactly what ExternalMediaActivity sends
   today". It was not: it carried transport=tcp, which the activity sends only
   when a caller sets Transport. The capture recorded parameters in prose and
   never the query string, which is how the mislabel survived. Measured now:
   the activity's real request returns 400 "transport must be 'tcp' for
   audiosocket encapsulation", and its DEFAULT configuration returns 200 with a
   UnicastRTP channel that no AudioSocket server ever sees. That is three
   defects, not two, and the review's own prediction for this case (501 not
   supported, read from resource_channels.c) was also wrong.

2. "Public API, additive, no CP0002" was false. The nine-parameter signature is
   shipped in two packages and PackageValidation runs against 2.5.3, so adding a
   parameter is CP0002 in both plus CP0006 on the interface; src/Verbara.Sdk has
   no CompatibilitySuppressions.xml today. Two NSubstitute setups stop compiling.
   The Impact section now prices this instead of denying it.

3. The capture explained an HTTP 500 as a reused channelId. Also wrong: crossing
   format against whether anything listens on the target port isolates it as a
   dead target port. A wrong "not a finding" note is worse than none.

The smaller fix that avoids the break entirely — mint the identifier, pass it
only as data — is now written down as a rejected alternative with the reason:
it fixes the activity and leaves a consumer holding only an ARI channel id
unable to find the stream, which is the capability itself.

Also: the probe is committed beside its capture and prints every query string;
A3 becomes an argument-capture test that can actually fail against unfixed code;
C1 subscribes a Stasis app before the create; C2 reverts the fix instead of
breaking the assertion; C3 applies the ci:functional label, which forces the
functional lane on a pull request and would have caught #302 before the queue;
F1's citation is corrected to :337 and :262; and F1-F3 become prose with a task
to open their change, because unchecked boxes are the one form the archive rule
says does not count.

openspec validate --all --strict: 14 passed, 0 failed.
Governance tests: 129 passed, 0 failed.
@Harol-Reina
Harol-Reina marked this pull request as ready for review September 24, 2026 10:48
@Harol-Reina Harol-Reina added the ci:functional Run the functional/Testcontainers matrix on this PR (ADR-0051 opt-in) label Sep 24, 2026
…ixed code

Phase A of externalmedia-returns-a-channel-id-that-finds-its-stream. The repo
rule is that a bug fix writes its failing test first and pastes the failure
verbatim, so this commit is deliberately red:

  Failed StartAsync_ShouldSendDataAndTcpTransport_WhenEncapsulationIsAudioSocket
  Expected sentData not to be <null> because an audiosocket create with no data
  is HTTP 400 "data can not be empty" (probe-capture.txt RUN D), so the activity
  must supply the identification uuid.

  Build: 0 Warning(s), 0 Error(s).  Failed: 1, Passed: 48.

It is an argument-capture test, not a "GetStream finds it" test: with a mocked
resource the test would choose both the returned Channel.Id and the uuid its own
client sends, and could be made to pass today. That closed loop is the defect
this change exists to correct.

Phase A also measured four things that were previously asserted:

  - Asterisk 22.9.0 and 23.4.1 behave identically on every probed run. 23 had
    never been probed; there is no version difference to carry into C1.
  - The uppercase-identifier miss is real: HTTP 200 with Channel.Id in the
    spelling sent, while the wire renders canonical lowercase into an ordinal
    dictionary. B2's negative control has a fixture instead of an argument.
  - probe-capture.txt's correction #2 was itself incomplete, the third time this
    capture has been wrong. A malformed `data` also returns 500, with the
    listener up: channelId is free-form, data must parse as a UUID. C2's third
    reversion must therefore use two well-formed UUIDs or it proves nothing.
  - RS0016 is globally suppressed by Directory.Build.props:15, so PublicAPI does
    not mechanically catch a NEW api row; only RS0017 (a Shipped row whose symbol
    vanished) is enforced. B1 cannot lean on the compiler for that half.

And three that change B1: it produces ~30 errors, not 3, of which 27 are false
NS1004 noise from NSubstitute's analyzer; AriResourceTests.cs:161 keeps passing
after the change while asserting nothing, and the `data=` appender it would
cover has never executed; and the default grep honours .gitignore, returning 14
hits where git grep returns 17.
Phase B of externalmedia-returns-a-channel-id-that-finds-its-stream.

ExternalMediaActivity could not reach a working AudioSocket stream by any
configuration: its defaults create an RTP channel nothing connects to, and
asking for audiosocket returned HTTP 400 because the activity sends neither the
transport that encapsulation requires nor the data Asterisk demands. Measured
against Asterisk 22.9.0 and 23.4.1, which behave identically.

CreateExternalMediaAsync gains channelId on IAriChannelsResource and
AriChannelsResource. The activity now derives its request from the server it was
handed: audiosocket implies transport=tcp, and one freshly minted UUID goes to
both channelId and data, so Channel.Id IS the identification UUID the stream
table is keyed by. An AudioSocketServer supplied against a non-AudioSocket
encapsulation is now refused instead of waiting out a 30-second timeout.

The mint is canonical lowercase and that is load-bearing, not incidental:
ParseUuid keys an ordinal dictionary with Guid.ToString(), and an uppercase
identifier returns HTTP 200 with Channel.Id in the spelling sent and a lookup
that misses. Measured, with a fixture.

BREAKING. The nine-parameter signature was shipped in two packages, so this is
CP0002 in both plus CP0006 on the interface, declared in CompatibilitySuppressions.xml
(a new file for Verbara.Sdk) with each entry read before it was kept, *REMOVED*
plus new rows in both PublicAPI.Unshipped.txt, and a migration guide per ADR-0028.
Callers that passed the cancellation token positionally get CS1503 — the parameter
was appended rather than placed first, because inserting it ahead of encapsulation
would have rebound every string? argument one slot over and gone on compiling.

Contract text on IAudioServer.GetStream, IAudioStream.ChannelId and both server
implementations now says what the key is and in what form, including that
WebSocketAudioServer keys on the last URL segment instead.

Verification, with the stale-green traps forced open:
  build           0 Warning(s), 0 Error(s)
  unit lane       3643 passed, 0 failed
  dotnet pack     exit 0, 29 packages — semaphores AND nupkgs deleted first,
                  because PackageValidation is incremental and a repeat pack
                  skips it entirely
  negative ctl    with CompatibilitySuppressions.xml removed, pack exits 1 with
                  exactly CP0002 + CP0006, proving validation actually ran
  A3 regression   was Failed: 1, now Passed: 1
…nd harvest the residuals

Phase C of externalmedia-returns-a-channel-id-that-finds-its-stream.

ExternalMediaChannelIdFunctionalTests originates a real externalMedia against
the container and asserts the stream resolves by the returned Channel.Id. It
subscribes a Stasis application first: externalMedia never checks that `app` is
registered, so the create returns 200 against a bare listener and Asterisk then
hangs the channel up within milliseconds of a 200 ms poll. Run on Asterisk
22.9.0 and 23.4.1, passing on both.

The functional project had no reference to Verbara.Sdk.Activities at all — the
class this change is about was unreachable from the only suite that can put a
real Asterisk on the other end.

Controlled by reverting the fix three ways, not by breaking the assertion.
Dropping `data` and dropping the tcp transport both fail at the create with the
400s the probe recorded; giving channelId and data two different well-formed
UUIDs returns 200, connects, and fails at the lookup. That third reversion first
produced "Asterisk did not connect to audio server" — a sentence that is false
about what happened and indistinguishable from Asterisk never connecting. The
assertion now prints the returned channel id beside the server's own table, so
the red shows the two identifiers having come apart.

C7 opens a-published-surface-is-one-something-measures for the three residuals,
and it corrected this change on three counts:

  - F3 is ten undefined extensions, not five. The suite reaches [test-functional]
    by two forms and both this change and the dialplan comment counted only
    Local/N@test-functional. Re-verified independently: 160 161 162 163 300 700
    750 999 9998 9999.
  - F1's line numbers aged inside this phase — B3's doc edits moved the key from
    :337 to :341 and its registration from :262 to :266.
  - The unasserting early return has four spellings and covers all eight facts in
    ConfBridgeAdvancedTests, not the five that failed.

And extensions.conf was carrying the exact ADR-0060 failure this change exists to
stop: it documented the hole, stated the wrong count, and ended "the hole is
filed separately" with no link. It now names the change.

CHANGELOG entry anchored on the #302 heading rather than on [Unreleased], since a
shared anchor is what ejected #300 from the queue with fourteen checks green.

  build       0 Warning(s), 0 Error(s)
  unit lane   3643 passed, 0 failed, 0 projects red
  openspec    15 passed, 0 failed
…at failed on the wrong command

All fourteen tasks of externalmedia-returns-a-channel-id-that-finds-its-stream
are checked off with their measurements.

C5 is the one worth reading. The first coverage run reported a red band — line
81.0% against a floor of 83.0, branch 62.51% against 64.0 — and it was not a
regression. It was the wrong command: no --settings coverlet.runsettings and no
-c Release. The tell was in its own output, 42125 lines measured against the
13322 that coverage-floor.json records as honest, three times the denominator.

With the exact command from ci.yml:87-93 all three gates pass: line 84.37% in
band, branch 68.81%, 13479 lines measured, patch coverage 100.0% at 22/22, zero
exclusion markers. The 22 was checked against the diff rather than believed —
the migration guide, CHANGELOG, suppression files, PublicAPI rows and openspec
artifacts carry no executable lines, and the functional test is excluded from
this lane, which is why the unit tests had to carry the activity's new lines.

C6 records that the pack negative control is not ceremony: PackageValidation is
incremental, and with the whole suppression file deleted a repeat pack still
exits 0 and prints 29 successful packages. Any future "pack is green" claim in
this repository has to say how it forced validation to run. #302's did not.
@Harol-Reina Harol-Reina changed the title docs(openspec): propose externalmedia-returns-a-channel-id-that-finds-its-stream fix(ari)!: externalMedia hands back a channel id that finds its stream Sep 24, 2026
activity.Channel?.Id ?? "no channel",
registered.Length == 0 ? "none" : string.Join(", ", registered));

activity.Channel.Should().NotBeNull(
@Harol-Reina
Harol-Reina added this pull request to the merge queue Sep 24, 2026
Merged via the queue into main with commit 9f2cbde Sep 24, 2026
15 checks passed
@Harol-Reina
Harol-Reina deleted the Harol-Reina/externalmedia-channel-id branch September 24, 2026 14:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:functional Run the functional/Testcontainers matrix on this PR (ADR-0051 opt-in)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants