From 563cfe7b4c6e9f11b6b587e8ca7b133c9596f9e2 Mon Sep 17 00:00:00 2001 From: "Harol A. Reina H." Date: Thu, 24 Sep 2026 09:31:34 -0500 Subject: [PATCH] docs(openspec): archive externalmedia-returns-a-channel-id-that-finds-its-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out #305, merged as 9f2cbde7. The change becomes openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream and its delta becomes the living spec openspec/specs/external-media-stream-routing. The CHANGELOG entry is relabelled from `Fixed — BREAKING` to `Changed — BREAKING`, and that is a ruling, not a tidy-up. ADR-0061 landed as #307 after the entry was written: only a `Changed — BREAKING` forces a minor, and D3 says an entry that cannot name the documented behaviour it restores is a `Changed`. This entry has two halves. The behavioural half restores what the docs promised — GetStream said it returned a stream by channel id and could not. The other half changes a shipped signature, so a caller that passed the cancellation token positionally stops compiling, and that restores nothing. Left as `Fixed`, D1 would have permitted the next release to ship that compile break as a patch. The spec's Purpose is written rather than left at TBD. It records that Asterisk decides this, not the SDK: `data` becomes the identification UUID the table is keyed by and `channelId` becomes the ARI Channel.Id, and supplying neither — the shipped behaviour — leaves a channel id that appears in no table. It also records why the answer had to be measured: reading the code produces a plausible answer, and the review that tried it produced a wrong one. Referrer sweep: two references, both by change name rather than by path, so the archive move breaks neither. The dependency line in a-published-surface-is-one-something-measures now reads as satisfied rather than pending, and names where the archived record and the live capability are. openspec validate --all --strict: 15 passed, 0 failed. Governance tests: 129 passed, 0 failed. --- CHANGELOG.md | 2 +- .../proposal.md | 10 +- .../.openspec.yaml | 0 .../probe-capture.txt | 0 .../probe-externalmedia.py | 0 .../proposal.md | 0 .../external-media-stream-routing/spec.md | 0 .../tasks.md | 0 .../external-media-stream-routing/spec.md | 104 ++++++++++++++++++ 9 files changed, 111 insertions(+), 5 deletions(-) rename openspec/changes/{externalmedia-returns-a-channel-id-that-finds-its-stream => archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream}/.openspec.yaml (100%) rename openspec/changes/{externalmedia-returns-a-channel-id-that-finds-its-stream => archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream}/probe-capture.txt (100%) rename openspec/changes/{externalmedia-returns-a-channel-id-that-finds-its-stream => archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream}/probe-externalmedia.py (100%) rename openspec/changes/{externalmedia-returns-a-channel-id-that-finds-its-stream => archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream}/proposal.md (100%) rename openspec/changes/{externalmedia-returns-a-channel-id-that-finds-its-stream => archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream}/specs/external-media-stream-routing/spec.md (100%) rename openspec/changes/{externalmedia-returns-a-channel-id-that-finds-its-stream => archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream}/tasks.md (100%) create mode 100644 openspec/specs/external-media-stream-routing/spec.md diff --git a/CHANGELOG.md b/CHANGELOG.md index fbcd2aad..18307993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -### Fixed — BREAKING: `ExternalMediaActivity` could not reach an AudioSocket stream by any configuration (#N) +### Changed — BREAKING: `ExternalMediaActivity` could not reach an AudioSocket stream by any configuration (#305) `ExternalMediaActivity` accepts an `AudioSocketServer` in its constructor and then polls `GetStream(Channel.Id)` for the stream. It could never hit. Measured against a real Asterisk 22.9.0 diff --git a/openspec/changes/a-published-surface-is-one-something-measures/proposal.md b/openspec/changes/a-published-surface-is-one-something-measures/proposal.md index 5aff95d5..0051dbaf 100644 --- a/openspec/changes/a-published-surface-is-one-something-measures/proposal.md +++ b/openspec/changes/a-published-surface-is-one-something-measures/proposal.md @@ -246,7 +246,9 @@ findings with no unknowns land first, so the unknown cannot hold them. - **CI:** F3's work is invisible on a `pull_request` without the `ci:functional` label (ADR-0051), and only `merge_group` runs both Asterisk versions. A sixteen-second green functional job means no Asterisk started. F1's probe depends on the same images. -- **Depends on** `externalmedia-returns-a-channel-id-that-finds-its-stream`: its delta creates the - `external-media-stream-routing` capability and explicitly excludes the WebSocket transport "until its - key has been measured". F1's delta adds to that capability and removes the exclusion, so this change - archives after its parent. +- **Builds on** `externalmedia-returns-a-channel-id-that-finds-its-stream`, which **landed as #305** + and is archived at `openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream`. + Its delta created the `external-media-stream-routing` capability, now live at + `openspec/specs/external-media-stream-routing/spec.md`, and that capability explicitly excludes the + WebSocket transport "until its key has been measured". F1's delta adds to it and removes the + exclusion. The dependency is therefore satisfied: this change is unblocked. diff --git a/openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/.openspec.yaml b/openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/.openspec.yaml similarity index 100% rename from openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/.openspec.yaml rename to openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/.openspec.yaml diff --git a/openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/probe-capture.txt b/openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/probe-capture.txt similarity index 100% rename from openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/probe-capture.txt rename to openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/probe-capture.txt diff --git a/openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/probe-externalmedia.py b/openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/probe-externalmedia.py similarity index 100% rename from openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/probe-externalmedia.py rename to openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/probe-externalmedia.py diff --git a/openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/proposal.md b/openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/proposal.md similarity index 100% rename from openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/proposal.md rename to openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/proposal.md diff --git a/openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/specs/external-media-stream-routing/spec.md b/openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/specs/external-media-stream-routing/spec.md similarity index 100% rename from openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/specs/external-media-stream-routing/spec.md rename to openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/specs/external-media-stream-routing/spec.md diff --git a/openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/tasks.md b/openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/tasks.md similarity index 100% rename from openspec/changes/externalmedia-returns-a-channel-id-that-finds-its-stream/tasks.md rename to openspec/changes/archive/2026-09-24-externalmedia-returns-a-channel-id-that-finds-its-stream/tasks.md diff --git a/openspec/specs/external-media-stream-routing/spec.md b/openspec/specs/external-media-stream-routing/spec.md new file mode 100644 index 00000000..6f1cebfb --- /dev/null +++ b/openspec/specs/external-media-stream-routing/spec.md @@ -0,0 +1,104 @@ +# external-media-stream-routing Specification + +## Purpose + +How an application that created an external media channel finds the audio stream that belongs to it. +The answer sounds like it should be free — the create call returns a channel, the server holds a table +of streams, look one up by the other — and it was not free. The two sides were keyed on different +identifiers drawn from different namespaces, and nothing in either contract said so. + +Asterisk decides this, not the SDK. For AudioSocket, the `data` parameter of +`POST /channels/externalMedia` becomes the UUID in the identification frame, which is what the stream +table is keyed by; the separate `channelId` parameter becomes the ARI `Channel.Id`. Supply one value +to both and the two are the same value. Supply neither — which is what this SDK did — and Asterisk +mints its own channel id, which appears in no table, so the lookup cannot succeed by construction. + +This capability exists because that could not be reasoned out. It was measured, with two distinct +byte-order-asymmetric UUIDs per request so the capture said both *which* parameter travelled and in +*which* byte order. Reading the code would have produced a plausible answer, and the review that +tried it produced a wrong one. + +So the requirements here cover three things that are easy to state and were each got wrong: that a +request the SDK builds must be one Asterisk can actually route, and must fail at the create rather +than as a connection timeout thirty seconds later; that the identifier's *form* is part of the +contract, because the table is an ordinal dictionary and an uppercase UUID creates a channel whose +stream cannot be found; and that a published lookup must say which identifier it takes, since the +same sentence — "Get an active stream by channel ID" — sat on an interface and on two implementations +that key on different things. + +The WebSocket transport is deliberately outside this capability until its key has been measured +rather than read. + +## Requirements + +### Requirement: An AudioSocket external media channel SHALL be findable by the channel id it returned +Where the transport identifies its connection with a caller-supplied value — AudioSocket does, in its +identification frame — the SDK SHALL make the ARI channel id and that value the same value, by +supplying one identifier to both parameters that carry them. + +The SDK SHALL NOT leave the ARI channel id to be minted by Asterisk while keying the stream table on +a different value, because no lookup can succeed across those two namespaces. The identifier SHALL be +in the canonical lowercase hyphenated form, which is the form the server's table is keyed by; an +identifier in any other spelling produces a successful create and a lookup that misses. + +The WebSocket transport is **not** covered by this capability until its key has been measured rather +than read from the code. + +#### Scenario: A stream is found by the channel id the create call returned +- **GIVEN** an AudioSocket server owned by the caller and running +- **WHEN** the caller creates an external media channel with AudioSocket encapsulation pointing at it +- **AND** Asterisk connects and sends its identification frame +- **THEN** looking the stream up by the returned `Channel.Id` SHALL return that stream +- **AND** the identification frame's UUID SHALL equal that same `Channel.Id` + +#### Scenario: An identifier in a non-canonical spelling does not silently miss +- **GIVEN** an identifier that is a valid UUID but not in canonical lowercase hyphenated form +- **WHEN** it is used to create an AudioSocket external media channel +- **THEN** the SDK SHALL either normalise it to the form the stream table is keyed by, or reject it +- **AND** SHALL NOT produce a created channel whose stream cannot be found + +### Requirement: A request that Asterisk cannot route SHALL fail at the create call +The SDK SHALL supply the transport that the requested encapsulation requires, rather than leaving a +combination Asterisk rejects. Where the caller has supplied an audio server whose transport +contradicts the requested encapsulation, the SDK SHALL fail before creating a channel that can never +carry a stream. + +A failure to create SHALL surface as the error Asterisk returned, at the create call, and SHALL NOT +be reported later as the audio server having failed to connect. + +#### Scenario: AudioSocket encapsulation carries its required transport +- **GIVEN** a caller that asks for AudioSocket encapsulation and specifies no transport +- **WHEN** the external media channel is created +- **THEN** the SDK SHALL send the transport AudioSocket requires +- **AND** the create SHALL NOT fail for want of a transport the caller was never asked for + +#### Scenario: An audio server that cannot be reached by the requested encapsulation is refused +- **GIVEN** an AudioSocket server supplied to an activity configured for a non-AudioSocket encapsulation +- **WHEN** the activity starts +- **THEN** it SHALL fail with an error naming the contradiction +- **AND** SHALL NOT wait out its connection timeout and report a connection failure + +### Requirement: The ARI external media surface SHALL expose the parameter that names the channel +The SDK's external media create method SHALL accept the ARI `channelId` parameter, which Asterisk +documents as the unique id to assign the channel on creation. Without it a caller cannot choose the +channel id, and therefore cannot make it match anything. + +Asterisk spells this parameter in camelCase while its siblings are snake_case, and ignores an +unrecognised parameter silently rather than rejecting it. The SDK's own test for the request SHALL +assert the literal parameter name, so a misspelling fails a test rather than becoming a silent no-op. + +#### Scenario: A caller chooses the channel id +- **GIVEN** a caller that has generated an identifier +- **WHEN** it creates an external media channel supplying that identifier as the channel id +- **THEN** the returned channel's id SHALL be that identifier + +### Requirement: A stream lookup contract SHALL say which identifier it takes and in what form +Any published contract for looking a stream up by key SHALL state what that key is, in what form, and +where a caller obtains it — rather than naming it only as a channel id. Where two implementations of +the same contract key on different values, the contract SHALL say so. + +#### Scenario: A reader can tell what to pass +- **GIVEN** the published documentation of a stream lookup +- **WHEN** a reader holds an ARI channel id and wants the stream for it +- **THEN** the documentation SHALL state whether that value is a valid key for that implementation +- **AND** SHALL state the form the key takes