REST API
Base path: /FCC. Style: POST-RPC, matching the FT GroupUpdate convention — most operations are a POST carrying a request body, even where the operation is conceptually a read. ui-backend proxies iot-webservice/iotw/FCC/ to /FCC/ one to one and never rewrites a body; it only adds the identity headers described below. All enum values on the wire are UPPER_SNAKE.
Identity Contract
ft-fcc-service has no authentication of its own — see No Authentication by Design. Every request is trusted purely on the strength of the headers below, which ui-backend injects and this service’s FccIdentityFilter reads.
| Header | Value | Mandatory |
|---|---|---|
|
|
Contractually yes, but its absence does not fail the request — audit columns fall back to the literal |
|
|
Only when |
|
|
Yes. Absent or unparseable → HTTP 403, never a wildcard. |
|
CSV of visible domain ids (no |
When scope is |
|
|
Contractually yes; carried for logging only in v1. |
|
The chunk reader ( |
|
Two different FT codes answer HTTP 403, and a client switching on the numeric code must handle both.
The last three digits of the code mirror the HTTP status everywhere except here, and deliberately: an identity that was never established is a different incident from a domain that was denied, and collapsing them would make the proxy misconfiguration indistinguishable from an ordinary authorization refusal in a log. |
The FT Error Code Catalog
FccErrorCodeEnum, in the 9xxx internal range. ui-backend is free to translate these into its own catalog — for example 9403 into the shared 9018 "domain denied".
| Code | HTTP | Raised by |
|---|---|---|
|
400 |
|
|
403 |
|
|
403 |
|
|
404 |
|
|
409 |
|
|
413 |
M3. |
|
415 |
M3. |
|
500 |
The catch-all. Never a stack trace; always the generic text. |
|
503 |
|
|
They exist at all because |
LIST vs. ALL Filtering
-
Scope
LIST: every read against this service’s own tables is narrowed withfcc_test.domain_id IN (:ids); every read againstftacsis narrowed withcpe.location_id IN (:ids)(seeFtacsDeviceResolveDao, which is the single place that column-name mapping is written down). If the caller is visible in more than 1000 domains (Oracle’sIN-list limit), the predicate is applied in memory instead against a single already-fetched row or a bounded fleet — seeFccDbConstants.IN_CLAUSE_MAX_EXPRESSIONS. -
Scope
ALL: no domain predicate at all. -
A test outside the caller’s visible domains is reported as
404 NOT_FOUND, never403: answering 403 would itself confirm that a test with that id exists in somebody else’s domain (FccTestService.loadVisibleTest).
The Empty Intersection Selects Nothing, Never Everything
Since M3 several bodies carry a domainIds field of their own — FccCatalogRequest, FccDeviceConditionRequest, FccDomainSettingListRequest.
They narrow within the caller’s scope and can never widen it, and one implementation decides that for every one of them: FccDomainScopeResolver, which intersects the ids the body names with the ids ui-backend sent.
The case worth writing down is the one that looks harmless.
When the intersection comes out empty — a scope-LIST caller naming only domains they cannot see — the resolver answers FccDomainScope.nothing() and the endpoint returns an empty result.
It does not answer an empty id list.
FccDomainScope |
What every DAO here does with it |
|---|---|
|
No domain predicate at all. Only a scope- |
|
|
|
The caller sees nothing. The DAO is not called; the service short-circuits to an empty answer. |
An empty IN list has no SQL form, so every DAO in this service reads an empty id list exactly the way it reads a null one: as "apply no domain predicate" — that is, as everything.
Collapsing an empty intersection into an empty list would therefore hand a caller who asked for somebody else’s domain the entire installation, and it would do it silently, on the one request that most deserves to be refused.
Four call sites depend on the distinction and all four short-circuit before any read: FccCatalogService.catalog returns an empty catalog (but still serves the tiers and the fleet limit, which are not domain data), FccDeviceResolveService.resolveFleet returns an empty fleet with every requested serial reported as not found, FccTestValidator.resolveDevices treats every submitted cpeId as unknown and answers the 400 the caller has earned, and — since M4 — FccReportScopeResolver.resolve (FccReportScopeResolver.java:92-94) short-circuits to FccResultFilter.nothing() the same way, before any of the four reporting endpoints reads a result row.
FccDomainSettingAdminService uses the same resolver for a single domain — one id is the degenerate case of the same rule, and a hand-written domainIds().contains(…) next to it is how the two would eventually disagree about who may read another operator’s configuration.
How fcc_test.domain_id Is Chosen on Create
| Scope | Rule |
|---|---|
|
Taken from |
|
Taken from the body’s |
Endpoints Implemented in M1
|
Four endpoints shipped in M1: M2 extended all four — the M3 extended |
PUT /FCC/item/details
Create or update a test and its child rows (config, speed settings, device fleet) in one transaction. A null id creates; a non-null id updates.
|
The units are no longer in the field names. These names are the frontend’s and the XML configuration document’s: the document derives its element names from the same record components, so one setting has one spelling in the JSON and in the file — see The Vocabulary Is Derived From the Request Records. The columns behind them keep their own older names ( |
Request (FccTestUpsertRequest):
| Field | Type | Notes |
|---|---|---|
|
Integer |
|
|
Integer |
Read only when scope is |
|
String, required, max 255 |
|
|
|
|
|
|
The repetition rule, which replaced the bare |
|
|
What to measure and on which devices: |
FccDiagnosticsSettingRequest, the one element of configs[].settings, carries both field sets, because a TypeScript union of two interfaces is one object shape on the wire. Three of its fields belong to either kind:
| Field | Notes |
|---|---|
|
The settings row’s id. Answered by the read and ignored by the save: there is one settings row per CONFIGURATION and it is found by its configuration, so an id on the way in has nothing to locate. It is accepted so a UI that round-trips a campaign need not strip fields out of what it was given. |
|
REQUIRED, and the ONE place the contract states the type. It selects which half of this object is read, which settings table the configuration stores and what the |
|
|
The kind-specific halves:
| Kind | Fields |
|---|---|
|
|
|
|
FccSpeedSettingRequest and FccLatencySettingRequest still exist and are still what the validator, the defaults chain, the entities and the XML configuration document are written against — the document takes its element names off their components by reflection. FccDiagnosticsSettingRequest is a boundary adapter with toSpeed() and toLatency(), which is why reshaping this one request body did not move the export format.
|
Response (FccIdResponse): {"id": 42}.
Example request:
PUT /FCC/item/details
X-FT-Domain-Scope: LIST
X-FT-Domain-Id: 17
X-FT-User-Id: 501
X-FT-Client-Type: mc
{
"id": null,
"name": "Downtown fiber compliance - Q3",
"activation": {
"type": "Scheduled",
"date": "2026-11-01T06:00:00Z",
"onlyOnlineDevices": true,
"totalTestDuration": 24,
"singleTestDuration": 30,
"batchInterval": 0,
"failedTestRetries": 1,
"wanUsageRetries": 3,
"requestDeviceConnect": false
},
"reactivation": {
"type": "Daily",
"repeatEvery": 1,
"startsOn": "2026-11-01T06:00:00Z",
"ends": "After",
"reactivationCount": 7
},
"configs": [
{
"id": null,
"manufacturer": "ALCL",
"model": "Nokia WiFi Beacon 4",
"sourceType": "All",
"conditionId": null,
"selectedDevices": [],
"unselectedDevices": [10236],
"settings": [
{
"wanDownloadThreshold": 5000,
"wanUploadThreshold": 1000,
"speedTierDownload": 100000,
"speedTierUpload": 20000,
"downloadUrl": "http://speedtest.example.net/download",
"uploadUrl": "http://speedtest.example.net/upload",
"downloadConnections": 4,
"uploadConnections": 2,
"fileSizeUpload": 50000000,
"testMode": "FILE_BASED"
}
]
}
]
}
Validation rules and the status they produce:
| Rule | Status | Detail |
|---|---|---|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
No default: guessing it from whichever field happens to be filled in is how a request that meant "these 12 devices" becomes a request for every device of a model. |
|
400 |
Without them it selects every device the caller can see, which no screen offers and a mis-click would otherwise save. |
|
400 |
The saved condition is the selection; without it the configuration names the manufacturer and model alone, which is a different and much larger fleet than the one the operator chose. |
|
400 |
The message names the column key and the operator — see sourceType=Condition: The Subset of the Portal’s Conditions This Service Translates. A condition over a device parameter, a custom field or a date, or one combined with |
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
|
|
400 |
Refused, not ignored: a file-based transfer ends when the file ends, so the value would be stored on the settings row, snapshotted onto the run and applied by nothing — with no way for the operator who typed it to find out. The same rule |
|
400 |
The interval is the STEP of the TR-143 |
|
400 |
|
|
400 |
|
|
400 |
M3. |
|
400 |
M3. |
|
400 |
M3. |
|
400 |
M3. |
|
400 |
|
|
400 |
|
A |
400 |
M3. Counted before a single row is read, and the message names both figures — see The Selection Limit Is Counted Before Any Row Is Read. |
|
400 |
|
Any submitted |
400 |
Reported as a list of the offending ids (capped at 20 in the message), resolved via |
Any device newly added to the test whose resolved serial is missing or blank |
400 |
Reported as a list of the offending |
Editing the device fleet on a test with |
409 |
|
Every accepted PUT /FCC/item/details also writes one fcc_audit row, CREATE or UPDATE, in the same transaction — see fcc_audit.
The Settings Are Resolved Before They Are Validated
The field rules above are applied to the resolved records — what will actually be stored and dispatched — and never to the raw body.
FccTestService.upsert walks the three-level chain (The Save-Time Settings Chain, From the Caller’s Side) first and hands validateSpeedSetting / validateLatencySetting the result.
Judging the body instead made the per-domain level of the chain unreachable in both directions, and it did so silently.
| Direction | What the earlier order did |
|---|---|
A domain default that satisfies a rule |
|
A domain default that breaks a rule |
A malformed per-domain |
One resolution, then one validation of its result.
validateConnectionCeilings is judged on the resolved record too, for the same reason.
|
It is about which block the body carries, which resolution cannot change and must not be allowed to hide.
A |
Two consequences of the order that a caller can observe:
-
An update loads its campaign before the body is judged. The domain whose overrides apply is the campaign’s own, not the caller’s — so a caller who may not see the campaign gets the 404 before any 400 about the body. That is the right order anyway: a body is only worth judging for somebody entitled to save it.
-
The chain is walked exactly once.
saveSettingsreceives the already-resolved records and resolves nothing itself; the values that were validated and the values that are stored are the same objects, which is what stops the two from ever differing.
The Settings Block Must Match the Type
FccTestValidator.validateSettingsMatchType refuses a body whose settings block does not match its type, in both directions: a missing block of the right kind, and a present block of the wrong kind.
The cross-check is what keeps a campaign runnable, and it is not symmetry for its own sake.
The type alone decides what the activation snapshot copies and what the ACTIVATE command is built from — FccActivationMapper.applySnapshot and FccCommandMapper both switch on it.
A LATENCY test that only carried speed settings would be saved happily, dispatch a command with no config.ping, and come back from the ACS as REJECTED(INVALID_CONFIG): a campaign that never runs, discovered hours later in the run history instead of immediately in the form the operator is looking at.
The extra block is refused rather than ignored for the same reason in reverse.
An operator who filled in a ping host and then switched the type to SPEED would otherwise get a saved test that silently measures something else.
On save, the settings row of the other kind is deleted — see fcc_test_setting_latency.
This is why POST /FCC/item/details returns exactly one of speed / latency and never both.
The Window Must Fit at Least One Iteration
iterations = totalDurationH * 60 / singleDurationMin
iterations >= 1
That expression is exactly the one the ACS derives its iteration count with (cross-repo contract §4), which is why it is checked here rather than approximated. A campaign that fails it — two hours of window with a three-hour iteration — would otherwise be accepted, dispatched, acknowledged, and then measure nothing at all: the ACS computes zero iterations, reports no results, and the run closes empty.
The 400 states the computed count, not just the two inputs, because "0 iterations" is the thing the operator has to understand before the two numbers make sense:
activation.totalTestDuration=2 and activation.singleTestDuration=180 give 0 iterations;
the window must fit at least 1
Integer division is deliberate here and matches the ACS: totalDurationH=1, singleDurationMin=45 gives one iteration, not 1.33.
|
Presence is judged at activation time, not at save time — the same two-level split as the serial rule (A Device Without a Serial Is Refused — Twice).
|
reactivationCount Is 0..9
0 — the default, and what the column defaults to — is a one-shot campaign.
N means the campaign produces N runs after the first; 9 is the product cap.
null is accepted and stores 0.
On the wire the field is reactivation.reactivationCount (DEV-1381 #10, PUT /FCC/item/details: Reactivation Is a Rule, Not a Count); in the schema it is fcc_test.reactivation_days, which has always held this count.
The bound matters because of what reads the value back.
FccTestStateMachine consults it through FccTestTransitionContext.hasMoreReactivations() (activationSeq < reactivationDays) to decide whether a finished run moves the campaign to SCHEDULED_FOR_REACTIVATION or to COMPLETED — see fcc_test Transition Table.
An out-of-range value would not fail anywhere; it would simply keep re-planning, and the campaign would measure for as long as the number said.
It is no longer overridable on POST /FCC/activate: the rule is stored with the campaign, and accepting the same figure twice let the two disagree.
A Device Without a Serial Is Refused — Twice
FccDeviceDto.serial is mandatory in wire contract 1.3: the ACS addresses a device by cpeId but identifies the measurement by serial, and it rejects a whole ACTIVATE carrying a blank one with REJECTED(INVALID_CONFIG). This service therefore refuses such a device itself, with HTTP 400, at two separate points:
| Checkpoint | What it catches |
|---|---|
|
A device that has no serial in |
|
The same condition on a fleet that was stored before the rule existed, or whose |
Why the save-time check stops at newly added devices. Once a test has activationCount > 0 its fleet is frozen — editing it is a 409, see the validation table above — so the operator cannot drop a device from it even if they want to. Were the save-time check to cover the stored fleet, a CPE that is factory-reset after the test was activated (its cpe.serial goes blank in ftacs.cpe) would make every later edit of that test fail with a 400 the operator has no way to clear: not a rename, not a threshold change, nothing. The test would be effectively bricked by a device it is not allowed to remove. Applied to the devices the operator is actually adding, the check is instead a 400 they can act on immediately — the offending ids are in their hands, in the request they just made. The frozen-fleet case is left deliberately to the activation-time gate, which refuses the run that would have failed at the ACS anyway and never blocks an unrelated edit.
The activation-time check is therefore not a redundant second pass — it is the only checkpoint that ever inspects an already-stored device. fcc_test_device is a snapshot taken at save time (fcc_test_device), so a fleet saved by an earlier version of this service, or imported, can perfectly well contain a serial-less row that no save-time check ever saw; and a fleet that was valid when it was frozen can acquire one afterwards.
Why refuse locally at all, rather than let the ACS answer REJECTED(INVALID_CONFIG): the ack is asynchronous and arrives seconds to minutes later, on a screen the operator has left. The rejection would fail the activation and move the test to ERROR with a reason that names neither the device nor the field — an operator with a campaign that silently never runs. A 400 on the request that caused it names the exact `cpeId`s while they can still be acted on.
POST /FCC/item/details
DEV-1381 #11: read one campaign in the shape the save accepts. {"id": 42} in, and the body of PUT /FCC/item/details out, under the very same names — so the screen that reads a campaign and the screen that writes one speak one vocabulary and a round trip needs no translation.
Response (FccTestDetailsResponse): id, name, state, activation, reactivation, configs[].
| Field | Type | Notes |
|---|---|---|
|
|
This service’s own lifecycle vocabulary, unchanged — the ticket’s example shows |
|
|
The timing knobs from |
|
|
|
|
|
One element per stored configuration, in the order they were saved — which is the order they were PUT in. |
FccTestConfigItem is not FccTestConfigRequest, and the three differences are the point:
| Field | Why it differs |
|---|---|
|
The saved condition’s name, read from the |
|
How many devices the frozen fleet holds. It cannot disagree with |
|
Has nothing to report on the read: the exclusions were applied when the fleet was resolved, so |
|
The choice is read, never re-derived from the fleet.
Answering the manufacturer its devices happen to carry today would look like information and be a guess: a mixed fleet has no single manufacturer, and a re-provisioned device would change what the campaign appears to have been configured with. |
ends Is Rendered, Not Echoed
There is no column for reactivation.ends, because the count is the only bound this product has: FccTestStateMachine stops re-planning when the run number reaches reactivation_days (reactivationCount Is 0..9). A stored rule with a count therefore means "after that many runs", and the read states that as After rather than leaving a screen to guess from a null.
settings is one object, whose own type says what the configuration measures, with the other kind’s fields null throughout — and an empty list for a draft that has no settings row yet, never a list holding null, which is a shape every client would have to defend against.
|
Two things this answer no longer carries.
|
A test id outside the caller’s visible domains returns 404 (see LIST vs. ALL Filtering).
POST /FCC/items
List tests, filtered, sorted and paged. The body is flat: the filters sit beside the paging, with no filters wrapper.
Request (FccItemsRequest):
| Field | Type | Notes |
|---|---|---|
|
String |
Selects campaigns by what is in their fleet — see Filtering the list by manufacturer or model. Matched case-insensitively and whole, not as a substring. Blank or absent means "do not filter". |
|
String |
The same, for |
|
|
Exact match, except for |
|
|
Absent or empty means |
|
Integer |
Defaults to 25, capped at 500. |
|
|
Each |
There is no domainId: which campaigns a caller may list is decided by the identity headers, never by the body. The list does show a domain column to a super-domain operator, but it is a column and not a control.
Response (FccPage<FccTestListItem>): pageDetails and items, and items is exactly Group Update’s GroupUpdateGroup field for field — it is the same table on the same screen, and a column that screen does not render is a column the frontend would have to be told to ignore:
| Field | Notes |
|---|---|
|
As stored. |
|
The domain’s name, read from |
|
Who saved the campaign, as the name |
|
Formatted in the caller’s zone and patterns (Where the domain name, the creator and the formatted dates come from). |
|
The same instants, unformatted. Sort and filter on these. |
|
When the latest run started; while a campaign waits on a schedule, when it is due. Empty for one that has never run. |
|
|
|
|
pageDetails is FccPageDetails — currentPage, pageItems, pageSize, totalItems, totalPages — which mirrors FTPageDetails of ft-iot-webservice/commons field for field and is shared by every paged endpoint of this service. Group Update populates three of the five and leaves currentPage and pageSize null; this list fills all five, because it can be asked for several pages at once and currentPage is how the answer says which ones it carries.
There is no type, deviceCount, domainId, source, scheduledAt or reactivationDays on the row. They were there before DEV-1381 was reconciled and were removed with it. type is the one to watch: nothing in this row says whether a campaign is a speed test or a latency test, which is the ticket’s shape and not an oversight here — if the screen turns out to need it, it is one field to restore.
Where the domain name, the creator and the formatted dates come from
Three of those columns cannot be produced from this service’s own data, and each is solved differently:
-
domainis read live fromftacs.isp, one batched query per page. That table is in the ACS schema, not iniotw:DomainEntityofapi-ormmaps toispand carries no@DataSourceSchema, soui-backendbinds it to the ACS persistence unit, and the read-only account this service already holds covers the whole schema (GRANT SELECT ON ftacs.* TO ftacs_ro). No new grant and no second datasource. The super domain has no row there at all, so the constant is substituted rather than queried.isp.nameis sent verbatim, dotted path included, because that is the stringui-backendshows.isp.idandisp.nameare in `FtacsSchemaCheckRunner’s required set, so an installation that narrows the grant to a table list fails at start-up naming them instead of on the first list request. -
creatoris stored, not resolved.iotw_userlives in a schema this service has no account on, soui-backendsendsX-FT-User-Nameand JPA auditing writes it into thecreatorcolumn when the campaign is saved. A rename does not rewrite history, which is what an audit column is for; the user id remains infcc_audit. -
created/updated/activatedare formatted withX-FT-Zone-Id,X-FT-Date-FormatandX-FT-Time-Format— the last three arguments of theDateTimeUtils.formatAcscallui-backendmakes for its own lists, so one portal cannot render a date two ways on two screens. All three are optional: the fallbacks are UTC,dd/MM/yyyyandHH:mm:ss, a zone or pattern this JVM cannot parse falls back with a warning, and the ISO twin beside each field carries the instant regardless. None of the four headers is an authorization input, which is why a missing one is never a refusal — unlikeX-FT-Domain-Scope.
Filtering the list by manufacturer or model
manufacturer and model select a campaign by what is in its fleet, not by a property of the campaign itself: a test is listed when at least one device of its fleet matches. Both are matched case-insensitively and whole, never as a substring — the values come from POST /FCC/manufacturerAndModelByTestType, and a substring match would let TP-Link quietly pull in TP-Link Deutschland, widening a compliance filter. A manufacturer name resolves through product_class.manuf_id with the product_class_group.manufacturer_name fallback, as every other read of the ACS catalog in this service does.
Naming both narrows. The device has to be of a model that is both — the cascade the catalog already implies, since a model belongs to a manufacturer and the wizard’s own model list is grouped under one. The alternative reading, "of this manufacturer or of this model", would answer a question nobody asks of a list screen: campaigns holding a device of that vendor beside a device of that model. The resolved set of model ids becomes one EXISTS over fcc_test_device, which then ANDs with name, type, state and the domain scope like any other filter.
Three consequences worth knowing:
-
It reads the frozen snapshot.
fcc_test_device.model_idwas resolved fromftacs.cpewhen the fleet was saved and is never refreshed, so a campaign keeps answering the manufacturer it was run against even after those devices have been re-provisioned onto another model or deleted from the ACS. The manufacturer-to-model expansion is the only part read live, and it is deliberately not narrowed to models somebody still owns devices of — last year’s campaign is exactly the one whose models may have none left. -
A value that matches no model lists no campaign, never every campaign, and the query is not issued at all. An empty
INlist reads as "no predicate" to a DAO, which is how a filter turns into a wildcard. -
A blank value is not a filter. An empty text box means the operator did not filter by it — the same reading
namebeside it has always had. There is no list here that could strip to nothing, so there is nothing to refuse.
A device row with a NULL model_id never matches. It is a device whose product_class the ACS did not know, so it belongs to no manufacturer anybody can name; such a device can only enter a fleet through a serial or cpe-id selection.
POST /FCC/activate
Implemented by FccActivationController / FccActivationService / FccActivationHandler.
Request (FccActivateRequest): {"ids": [42, 43]} — the ids and nothing else. Duplicates are collapsed before anything is dispatched, and each surviving id is processed once, in request order.
Response: true, or an error. Never false — the same contract as DELETE /FCC/items, and `GroupUpdateService.activateGroups’ behaviour exactly.
When a Campaign Runs Is Decided by the Save, Not by This Call
There is no mode and no scheduledAt on this body. The timing is activation.type and activation.date of PUT /FCC/item/details, stored on fcc_test.scheduled_at, and this endpoint commits whatever was booked:
| Stored state | What activation does |
|---|---|
No |
Creates the run and writes its |
|
Moves the campaign to |
|
Refused for that id, |
That is DEV-1381’s division of labour and it is the better one: a schedule is a property of the campaign the operator configured, not of the click that starts it, so it survives a reload of the screen and comes back from POST /FCC/item/details beside everything else they set. It also removed the one way the two could disagree — a campaign booked for Friday activated with mode=IMMEDIATELY.
reactivationDays is gone from this body too: it is stored on the campaign by the save, and accepting it again here meant the same figure could be set in two places and disagree.
|
Every campaign is activated in its own transaction, and a failure part way through is not rolled back. The earlier runs are started and their The status is the most severe outcome of the batch — The whole call is refused while the result store is unavailable, booked campaigns included. A campaign booked now would start hours later against a store that may still be unreachable and produce exactly the hole in a regulatory report that refusal exists to prevent — except nobody would be watching by then. Now is the only moment an operator is present to be told. |
A Booked Campaign Creates No Activation Row
FccActivationHandler.schedule moves the campaign to SCHEDULED, writes fcc_test.scheduled_at, and stops there. Nothing is dispatched and the call still answers true: what it committed is the booking.
That is the whole design, not an omission.
A run carries a settings snapshot, and a snapshot taken now for a campaign that starts next Tuesday would freeze the configuration for a week — the opposite of the product rule that an edit applies from the next run.
The instant lives on the campaign; FccActivationScheduler claims it when it arrives and calls the ordinary activate, which snapshots at that moment.
A past booked instant is refused for that id, never a silent immediate start. "Scheduled for yesterday" is an operator mistake — a wrong date, a timezone the UI resolved differently — and running the campaign immediately would disturb a whole fleet on the strength of a typo. The message says to edit the date on the campaign or clear it.
The ClickHouse-unavailable refusal covers a booked campaign as well, and that is not the lenient case: a campaign booked while the result store is down would start hours later against a store that may still be unreachable, producing exactly the hole in the regulatory report the refusal exists to prevent — except nobody would be watching by then. Refusing now is the only moment at which an operator is present to be told.
The states a campaign may be scheduled from are not the same set it may be activated from, so the denial has to come from the event the caller actually asked for (FccTestValidator.validateSchedulable, which evaluates ACTIVATE_SCHEDULED rather than ACTIVATE_IMMEDIATELY).
The one difference: a campaign in SCHEDULED_FOR_REACTIVATION accepts an immediate run and refuses being re-scheduled, because it already owes a PLANNED run whose window nothing on this endpoint would move.
Response: true, or an error whose status is the most severe outcome of the batch — see the FccBulkOutcome note above. FccActivateResponse and FccActivateResultItem are gone with DEV-1381: a booked campaign used to be told apart from a refused one by accepted: true with a null activationId, and now a booking is simply part of what true means.
Example request and response:
POST /FCC/activate
X-FT-Domain-Scope: LIST
X-FT-Domain-Id: 17
X-FT-User-Id: 501
X-FT-Client-Type: mc
{
"ids": [42, 43]
}
true
Status codes:
| Status | When |
|---|---|
200 |
|
400 |
The body failed bean validation ( |
503 |
The result store cannot take rows — either it is unreachable, or reachable with its ClickHouse schema behind ( |
The states a test may be activated from, and the pre-flight requirements, are enforced by FccTestValidator and read from the code, not restated from the contract:
| Check | Detail |
|---|---|
Activatable states ( |
Since M2 this is no longer a local |
Activating a campaign in |
The campaign is between daily runs and already holds its next one as a |
Schedulable states ( |
The |
Pre-flight completeness ( |
Reported together as one message listing every missing item: at least one device ( |
|
The two machine-driven entry points — a due |
The serial check is a 400-shaped validation failure like every other entry in that list, so it refuses that id and contributes a 400 to the batch’s severity ranking. Nothing is written to fcc_activation or fcc_acs_outbox for a test refused this way: the command is never built, so no ACTIVATE with a blank serial ever reaches the queue.
Access is the same as every other endpoint: FccTestAccessResolver.loadVisibleTest narrows by domain scope exactly as LIST vs. ALL Filtering describes. A test outside the caller’s visible domains throws FccNotFoundException ("no test with id=…"), which FccActivationService.activateOne catches like every other FccException and records as that id’s failure reason; only an unexpected, non-FccException failure is masked behind the generic "activation failed" text.
Endpoints Implemented in M2
Five new endpoints, plus mode=SCHEDULED and the LATENCY type on the four M1 ones.
Three of the five — POST /FCC/stop, POST /FCC/pause and DELETE /FCC/items — follow the same envelope the M1 bulk endpoints established: partial success is the normal outcome, each id is processed in its own transaction with its own result item, ids are de-duplicated first (a UI that sends the same id twice gets one outcome, not one success and one 409 about a campaign its own first request stopped), and the HTTP status is 200 whenever the request itself was valid. The other two do not carry that envelope at all: POST /FCC/devices/status (FccDevicesStatusRequest) takes a single @NotNull Integer id with no result items of its own, and POST /FCC/admin/result-dead-letter/resend (FccDeadLetterResendRequest) takes {limit} only — neither is a bulk-by-id operation, so neither has a per-id outcome list to be partial about.
|
|
POST /FCC/stop
Stop one or more campaigns and the runs they own.
FccActivationController → FccTestLifecycleService.stop → FccStopService, which is the one implementation of "stop the live run", shared by stop, pause and delete.
Request (FccStopRequest): {"ids": [42, 43]}. ids is required and non-empty, and every element must be non-null — {"ids":[null]} would otherwise reach the service layer and surface as a result item with a null testId.
Response: true, or an error. Never false — the same contract as DELETE /FCC/items.
|
A STOPPED CAMPAIGN IS
The constant stays in the |
|
Every campaign is stopped in its own transaction, so a failure part way through leaves the earlier ones stopped and their STOP commands queued. The error names how many were stopped and which ids were not, and the status is the most severe outcome of the batch — A STOP that was never queued for the ACS is a success, not a failure. It means the campaign owned no run, or the run was still The result store is not consulted. Activation refuses to start anything while ClickHouse is unreachable; refusing a STOP would leave the fleet measuring into a store that is already down, which is strictly worse than letting the operator stop it. |
What one accepted stop writes, all in one transaction per campaign:
-
the campaign transition
USER_STOP—RUNNING/SCHEDULED_FOR_REACTIVATION→NOT_ACTIVE, andSCHEDULED/PAUSED→NOT_ACTIVEwithscheduled_atcleared; -
the run transition
STOP_REQUESTED→STOPPINGwithstop_reason = USER_STOPandstopped_atset — for a campaign that owns a live run, which aSCHEDULEDorPAUSEDone does not (see A Paused Campaign Can Be Stopped); -
fcc_test.command_seq + 1and a STOP row infcc_acs_outboxwith a fresh idempotency key; -
an
fcc_auditrow,action = STOP, with the caller’s user id.
They commit together or not at all: a STOP whose sequence was burnt by a rolled-back transaction would make every later command look stale to the ACS.
The run then stays STOPPING until its in-flight devices drain or ft.fcc.stop-grace expires — see STOP and Stop and Drain.
Status codes:
| Status | When |
|---|---|
200 |
|
400 |
The request body fails bean validation ( |
403 |
The identity contract was not satisfied — see Identity Contract. This is the only status the endpoint itself can answer with beyond 200/400. |
Stopping an Already-Stopped Campaign Is a Refusal, Not a No-Op
A campaign whose state does not accept USER_STOP is refused per id, with the state machine’s own denial text folded into error:
{"testId": 43, "accepted": false, "activationId": null, "commandPublished": null,
"error": "test id=43 cannot be stopped: test state NOT_ACTIVE does not accept USER_STOP"}
Answering "done" would hide the state the operator is actually looking at — usually a campaign somebody else stopped a minute earlier.
This is the one place a Denied becomes an error rather than a log line, and it is exactly because a user asked for it: see The State Machine as the Single Decision Point.
The refused states are NOT_ACTIVE, STOPPED, COMPLETED, ERROR and the unreachable ARCHIVED.
PAUSED is not among them — it is the one closed state a stop still changes something about, see A Paused Campaign Can Be Stopped.
The accepted states are therefore RUNNING, SCHEDULED, SCHEDULED_FOR_REACTIVATION and PAUSED, which is the USER_STOP column of the campaign transition table and not a list this endpoint keeps.
commandPublished: false Is Not a Failure
Two cases produce it, and both are successful stops:
-
There was no run at all. A campaign in
SCHEDULEDowns only a pending instant; clearing it is the whole stop. -
The run was still
PLANNED. NoACTIVATEever reached the ACS, so there is nothing there to stop. The run is closed locally asSTOPPEDand no command is written — sending one would burn a command sequence for a run the ACS has never heard of and leave the row inSTOPPINGuntil the stop grace expired. See the PLANNED divergence.
A UI that showed "stopped" for a published command and "failed" otherwise would report both of these as failures.
POST /FCC/pause
Same request and the same response as POST /FCC/stop — {"ids": […]} and true, with the identical per-batch error. One implementation serves both; only the stop reason and the state the campaign lands in differ, and the error wording names the one the operator asked for so a failed pause does not read as a failed stop.
|
A pause IS a stop.
It is the identical code path with Resume is a plain |
PAUSED therefore says one thing a stop does not — the operator means to come back to this campaign — and nothing more. Group Update’s pause says more than that: it keeps prev_state, leaves its unsent tasks queued and resumes its Quartz jobs, so its Activate continues from where it stopped. Nothing here does, which is exactly why a stop reads NOT_ACTIVE (see [stop-lands-in-not-active]) and a pause keeps a state of its own.
The difference from a stop is therefore entirely local and entirely in the labels, and one of those labels is load-bearing: stop_reason = USER_PAUSE is what stops a later ACTIVATION_STOPPED from the ACS from turning the pause into a stop.
The campaign has already been moved by this endpoint, and feeding the run’s own stop report on top of it would be accepted by RUNNING + ACTIVATION_STOPPED → STOPPED, so nothing downstream would refuse it.
A Paused Campaign Can Be Stopped
POST /FCC/stop on a PAUSED campaign is accepted and lands it in NOT_ACTIVE.
It was not, and that was the defect: PAUSED was evaluated by the same branch of the state machine as NOT_ACTIVE, STOPPED, COMPLETED and ERROR, which allows only the ACTIVATE_* events, so the stop answered
{"testId": 43, "accepted": false, "activationId": null, "commandPublished": null,
"error": "test id=43 cannot be stopped: test state PAUSED does not accept USER_STOP"}
and an operator who had paused a campaign had no way to close it: the only route to NOT_ACTIVE was to activate it again — starting a whole new run on the fleet — and stop that.
The validator pointed the same way: a new booking on a paused campaign is refused with "test is PAUSED and its schedule cannot be changed by a save; stop it first, then book it again", which could not be done either.
Nothing new reaches the ACS, and that is the whole shape of this stop:
-
no STOP command is written. The pause already sent one for every configuration of the run, and those runs are draining in
STOPPING— a stateFccStopService.LIVE_STATESdeliberately excludes, because a second command would burn a command sequence to repeat an instruction the ACS is already carrying out. The log line readsactivationIds=[] commandsPublished=0, which is a successful stop with nothing to publish; -
the draining runs keep
stop_reason = USER_PAUSE. There is nothing to re-stamp, and that reason is load-bearing: it is what keeps theACTIVATION_STOPPEDthe ACS reports when they drain from reaching the campaign, so a stopped-after-pause campaign cannot slide fromNOT_ACTIVEintoSTOPPED; -
any pending instant is cleared, which is this transition’s only effect — see why
CLEAR_SCHEDULED_ATand notSTOP_ACTIVE_ACTIVATION.
POST /FCC/pause on an already-paused campaign stays a refusal (test state PAUSED does not accept USER_PAUSE), for the reason a stop of a closed campaign is one: nothing would change, and the 409 names the state the operator is actually looking at.
FccStopAndPauseIT.aPausedCampaignCanBeStoppedAndThereIsNothingLeftToStop is the test.
That rule has one implementation (FccActivationTransitionService.isUserInitiatedStop) used by all three call sites — see Activation Event Semantics.
Which of the two an operation is, is the URL’s job and not a flag in the body.
A flag would let a caller pause through the stop endpoint and make the audit trail unreadable; the trail records PAUSE and STOP as separate actions.
Response: true, or an error. Never false — the same contract as DELETE /FCC/items. Every campaign is handled in its own transaction, so a failure part way through leaves the earlier ones paused and their STOP commands queued; the error names how many were paused and which ids were not, and the status is the most severe outcome of the batch (FccBulkOutcome).
DELETE /FCC/items
Remove one or more campaigns, stopping whatever they still have running first.
A body on a DELETE is the FT FE-BE contract’s bulk-delete shape, not an accident.
Request (FccDeleteRequest): {"ids": [42, 43]}, same constraints as FccStopRequest.
Response: true, or an error. Never false.
That is DEV-1381’s contract and it is `GroupUpdateService.deleteGroups’ behaviour exactly — the boolean means "the call completed", a failure is an error response, and there is no third answer. The per-campaign result items this endpoint used to return are gone with it; what a deletion did to each campaign is in the log line and the audit trail, which is where a deletion’s history belonged anyway.
|
Every campaign is deleted in its own transaction, and a failure part way through is not rolled back. The earlier ids are deleted, and a STOP may already have reached the ACS for them. The boolean cannot say that, so the error does: the description names how many were deleted and every id that failed with its reason, which is what lets the caller re-send exactly the ones that did not. The status is the most severe outcome of the batch — |
Duplicate ids are collapsed before anything is deleted: a UI that sends the same id twice gets one deletion, not a success and a 404 about a campaign its own first id removed.
Deletion Is a UI Removal, Not an Erasure
This is the load-bearing decision of the endpoint, and what is left behind is not housekeeping left undone.
| Table | Fate | Why |
|---|---|---|
|
Deleted |
The campaign’s definition — the rows the operator authored. That is what they are asking to remove. |
|
Kept |
The run history. Each row carries its own settings snapshot precisely so a run stays readable after the campaign it came from was edited — or removed — and a compliance report is built from those rows. Deleting them would make measurements that were genuinely taken disappear from a regulatory record, which is the one thing this product exists not to do. |
|
Kept |
It still has work to do. The STOP written a few statements earlier has not been sent yet — |
|
Kept |
All three are keyed on |
|
Kept, and one |
The trail outlives the campaign; this is the one case where it is the only record that the campaign ever existed. |
The campaign therefore disappears from every list and can no longer be run, edited or resumed; its history does not disappear from the database.
A campaign whose results must genuinely be erased — a data-subject request, a mistaken import — is out of scope for M2 and needs a separate, explicitly destructive operation over fcc_activation and both ClickHouse tables.
Nothing downstream is surprised by the missing campaign: FccActivationTransitionService.applyTest already treats an absent fcc_test as a reachable case and leaves the run alone, and the watchdog closes a draining run by its own state and instant without consulting the campaign at all.
Delete Stops the Run but Skips the Campaign Transition
FccTestDeleteHandler calls only the run half of FccStopService, never stop().
Moving the campaign to STOPPED first would write a state nobody can ever read, since the row is deleted a few statements later — and it would refuse exactly the campaigns that most need their run stopped: a campaign already in STOPPED whose run is still draining, or a COMPLETED one still carrying a PLANNED reactivation the poller skipped.
A conflict raised inside this transaction would also be unrecoverable, since the stop service joins it and Spring would mark it rollback-only.
The set of run states treated as live is correspondingly wider here than on the stop path — it additionally includes STOPPING.
A draining run is invisible to a stop (it has its STOP already), but deleting the campaign around it silently is how a fleet keeps measuring for a campaign nobody can see any more.
Such a run gets no second command; it is detected so the operator is told about it in the response (stoppedActivationId set, commandPublished: false).
Visibility is resolved inside the delete transaction, unlike the stop path where the bulk loop resolves it: a delete is irreversible, so the check that decides whether this caller may see the campaign and the delete itself read the same row in the same transaction rather than two rows a moment apart.
POST /FCC/devices/status
DEV-1381 #9, "the same as POST /GroupUpdate/devices/status`": `{"id": 0} in, six counters out.
Read-only, and it reads fcc_device_state — where each CPE is — never the measurements, which live in ClickHouse and answer a different question.
Request (FccDevicesStatusRequest): a single required id, Group Update’s IntIdRequest exactly.
Response (FccDevicesStatusResponse), DevicesStatusResponse field for field:
| Field | Counts |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Computed in one grouped query rather than six separate counts, because a run holds up to 20000 devices and this screen refreshes while the operator watches it.
A state no device is in does not come back from the query and is rendered as 0.
|
The six are a projection of the nine states this service keeps, and two of the nine have no category of their own. Where those two land is the whole of the design decision, because it decides whether a compliance screen tells the truth. The rule each fold follows: never claim a measurement that was not taken, and never report a breach that did not happen.
Group Update’s answer is a projection too: its What is lost is lost only in this answer. All nine states stay in |
Three behaviours worth knowing:
-
It names no run. The counters are always those of the campaign’s newest run, because the question this screen asks is "how is it going right now". The
activationIdthis endpoint used to accept is gone: an earlier run’s figures come from the reporting endpoints, whose filters take one, and two ways to ask for the same figures is how the two come to disagree. It also removes a check that used to be an authorization boundary —fcc_device_stateis keyed onactivation_idalone, so a caller naming a run id had to be stopped from reading another domain’s fleet by guessing one. The run is now reached only through the campaign, so the boundary isFccTestAccessResolverand nothing else. -
A campaign that has never been activated answers six zeros — not a 404 about a campaign that plainly exists. Group Update answers the same way: its grouped query simply comes back empty.
-
There is no
total. It is the sum of the six, and a seventh field the contract does not name is a field a client has to be told to ignore.
The per-device grid this endpoint used to carry is gone with it — cpeId, serial, state, stateReason, iteration, wanRetries, failedRetries, skipCount, observedAt, paged by cpeId. DEV-1381 #9 is the counters endpoint, and in Group Update the per-device rows are a different read (GroupUpdateDeviceReport, served with the group’s details). No other endpoint of this service answers "which devices are offline" or "which never had anything sent": POST /FCC/reports/results reports measurements, so a device that measured nothing has no row there. If the run screen needs that table, it needs a path of its own — most likely alongside the campaign details payload — and the code is one commit away in the history of this file.
|
POST /FCC/admin/result-dead-letter/resend
Feed parked measurements back through the ingest path.
FccAdminController, under /FCC/admin/* — which is inside /FCC/ and therefore goes through FccIdentityFilter exactly like every other path of this service.
Nothing here belongs to the UI’s normal flow; these are the levers a human pulls after an incident.
Request (FccDeadLetterResendRequest): {"limit": 500}.
Response (FccDeadLetterResendResponse): {"resent": 431}.
| Field | Notes |
|---|---|
|
How many |
|
How many measurements were handed back to the ingest path. It is not the number of rows read: unreadable rows are discarded and do not count as recovered. It is also not "already in ClickHouse" — the ingest path buffers and writes asynchronously, and a row whose write fails again takes the same park path as a live measurement and is parked anew, as a new row. |
The read is WHERE state = 'PENDING' AND reason <> 'LATE' ORDER BY created, id, so two outcomes per row:
| Row | What happens |
|---|---|
|
Deserialized from the JSON payload and replayed through |
Payload cannot be read |
Marked |
LATE rows never reach either branch: they are written straight as DISCARDED, and the read filters reason <> 'LATE' on top of that — see A LATE Row Is Parked Straight Into DISCARDED.
A row from an earlier build that is still PENDING and LATE is skipped and counted (fcc.result.resend.skipped.late), because that counter is the one signal that the table holds rows the recovery path cannot use. There is no per-row WARN for this — only an aggregate log.info naming the count once the resend call finishes, which is enough to notice the condition without a line per stale row.
|
Each row is replayed under the instant it actually arrived under, not under the clock of the resend. The rows are grouped by |
|
The resend is itself refused with 503 while the result store cannot take rows ( It is the same |
|
There is no resend poller. This endpoint is the only trigger; nothing drains |
Endpoints Implemented in M3
Twelve new operations over eight paths: three wizard reads, POST /FCC/item/clone (removed since — see the frozen-fleet row of PUT /FCC/item/details), and eight operations over four paths on the two administration tables on FccAdminController — plus a device selection as an alternative to an explicit list on PUT /FCC/item/details and four new validators there.
The three wizard reads (POST /FCC/manufacturerAndModelByTestType, POST /FCC/devices/resolve, POST /FCC/devices/count) are the first endpoints of this service that read ftacs for anything other than validating a cpeId handed to them.
They write nothing: a fleet becomes a campaign only when PUT /FCC/item/details stores it, and that path re-resolves the very same selection through the very same service (PUT /FCC/item/details: Where the Fleet Comes From).
|
The OpenAPI snapshot merges the two doubled paths.
|
POST /FCC/manufacturerAndModelByTestType
Step 1 of the campaign wizard, in one call: the manufacturers and models the caller actually owns devices of, the tier presets for the chosen test type, and the fleet limit.
FccDeviceController → FccCatalogService → FtacsCatalogDao + FccModelCapabilityProvider.
Request (FccCatalogRequest):
| Field | Type | Notes |
|---|---|---|
|
|
Required, and it selects the tier presets — not the models. See The Test Type Does Not Narrow the Model Catalog. |
|
Boolean |
|
|
|
Narrows within the caller’s scope and can never widen it — see The Empty Intersection Selects Nothing, Never Everything. |
Response (FccCatalogResponse):
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
|
|
Integer |
|
FccCatalogModelItem:
| Field | Type | Notes |
|---|---|---|
|
Integer |
|
|
String |
|
|
Integer |
|
|
String |
|
|
Long |
The caller’s own, not the installation’s: two operators of different domains see the same model with different numbers, and a model nobody in their domains owns is absent entirely. |
|
Integer |
From |
|
Boolean |
From the same row, |
Example:
POST /FCC/manufacturerAndModelByTestType
X-FT-Domain-Scope: LIST
X-FT-Domain-Ids: 17,18
X-FT-User-Id: 501
{"type": "SPEED", "onlyOnline": false, "domainIds": [17]}
{
"manufacturers": [
{
"manufacturerId": 4,
"manufacturer": "Sagemcom",
"deviceCount": 1840,
"models": [
{"modelId": 311, "model": "F@st 5657", "productClassGroupId": 12,
"protocol": "TR181", "deviceCount": 1200, "onlineCount": 1043,
"connectionsDlMax": 4, "connectionsUlMax": 2, "supportsTimeBased": false},
{"modelId": 402, "model": "F@st 3890", "productClassGroupId": 12,
"protocol": "TR069", "deviceCount": 640, "onlineCount": 502,
"connectionsDlMax": null, "connectionsUlMax": null, "supportsTimeBased": false}
]
}
],
"tiers": [
{"label": "1/0.25", "dlKbps": 1000, "ulKbps": 250},
{"label": "100/20", "dlKbps": 100000, "ulKbps": 20000},
{"label": "1000/500", "dlKbps": 1000000, "ulKbps": 500000}
],
"maxDevicesPerTest": 20000
}
Refusals:
| Rule | Status / FT code | Detail |
|---|---|---|
|
400 / |
Bean validation, |
Identity contract not satisfied |
403 / |
See Identity Contract. The identity filter uses |
A scope-LIST caller who names only domains they cannot see gets manufacturers: [] with the tiers and the limit still filled in — never an unfiltered catalog. See The Empty Intersection Selects Nothing, Never Everything.
The Catalog Is Grouped by Manufacturer NAME, and manufacturerId May Be Null
FccCatalogService buckets the models by their resolved display name, case- and whitespace-insensitively, and not by product_class.manuf_id.
The difference is not cosmetic.
manuf_id is nullable, and a null manuf_id is precisely the case the product_class_group.manufacturer_name fallback exists for.
Keying the buckets by it therefore dropped every such model into one null bucket, which was then labelled with whichever non-blank name happened to sort first — a bucket reading "ZYXEL" holding Huawei and Sagemcom models, with their device counts summed under it.
The name is also the thing the operator actually sees and picks by, and it is the only identifier every model of the catalog has.
It is matched case-insensitively for the same reason condition.manufacturers is: the two halves have to agree on what one manufacturer is.
|
Grouping by name means a bucket can legitimately hold rows of two different So
|
The Test Type Does Not Narrow the Model Catalog
The endpoint is named after the type and the parameter is required, and it still does not filter a single model. Do not "fix" this into a filter.
Every TR-069 CPE that can run DownloadDiagnostics can also run IPPing, and there is no per-model datum anywhere in the ACS schema that would tell a speed-capable model from a latency-capable one.
A filter would therefore have to be invented, and an invented filter on this screen removes models the operator genuinely owns and genuinely can measure — a fleet that quietly cannot be assembled, for a reason no message explains.
What the type actually selects is the tier presets, which are genuinely different for the two (a download/upload pair versus a single rate, see Tiers Are Served, Not Hard-Coded in the UI).
The parameter stays in the contract rather than being dropped, so that the day a model capability does distinguish the two the frontend does not have to change.
fcc_model_capability is not that day: it records connection ceilings and a time-based flag, none of which says anything about ping.
Tiers Are Served, Not Hard-Coded in the UI
FccTierPresets is the single source of the list, and the frontend brief forbids the UI from carrying backend-owned defaults.
This is one of them, and for a concrete reason: the very same kbps figure is what the pass/fail rule compares a measurement against (The Pass/Fail Formula), so a second copy in a TypeScript constant would eventually decide that a campaign passed while this service decided it failed.
kbps on the wire, Mbps on the label, and 1 Mbps = 1000 kbps exactly — never 1024.
The cross-repo contract is explicit that a tier is never divided by 1024; a tier that travelled as Mbps and was converted with a binary factor would move every threshold by 2.4%, which is precisely the size of margin a compliance verdict turns on.
The labels are the tier as an operator says it out loud, which is also how it appears in a regulatory report: "100/20" for speed, "100" for latency.
A latency tier has no upload side. ulKbps is null rather than a repeat of the download figure: a ping is symmetric, and the tier only says which subscriber plan the latency threshold belongs to — the contract has FccTiersDto.ulKbps null for a latency test throughout.
|
"Manual" is a UI affordance, and a non-preset tier is a supported case. There is no Zero is still refused, and that is not inconsistency: a tier of zero makes every measurement infinitely over-performing and |
POST /FCC/devices/resolve
Turn a selection into the fleet it names: one page of devices, and the figures the operator decides by.
FccDeviceController → FccDeviceResolveService → FtacsDeviceResolveDao.
The full mechanics of the read — the ACS columns, the condition set, the SQL rules — are their own page: Device Resolution.
Request (FccDeviceResolveRequest):
| Field | Type | Notes |
|---|---|---|
|
|
Decides which half of the body is read. There is no default, and guessing it from whichever field happens to be filled in is how a request that meant "these 12 serials" becomes a request for the whole installation. |
|
|
Read for |
|
|
Read for |
|
Integer, 1-based |
Defaults to 1 if absent or |
|
Integer |
Defaults to |
FccDeviceConditionRequest — every field optional, present ones combined with AND, no two fields ever OR-ed:
| Field | Type | Predicate |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
String |
|
|
Boolean |
|
|
|
Narrows within the caller’s scope — see The Empty Intersection Selects Nothing, Never Everything. |
Response (FccDeviceResolveResponse):
| Field | Type | Notes |
|---|---|---|
|
|
One page, sliced in memory from the ordered full match. |
|
|
The same |
|
Long |
Every counter describes the whole match, never the page. They are what the operator decides on and what ends up in the campaign, so a counter that quietly meant "on this page" would understate a fleet by the page size. |
|
|
|
|
|
See notFoundSerials and duplicateSerials Are Two Different Defects. |
|
|
Same. |
One read serves the page, every counter and both serial reports.
FccDeviceResolveService.read performs a single COUNT(*), then a single find, and derives everything else from the rows in hand rather than from further aggregate queries.
That is what keeps the figures consistent with one another: a device that goes online between two statements cannot make the sum of the per-model counts disagree with the total.
Example:
POST /FCC/devices/resolve
Content-Type: application/json
X-FT-Domain-Scope: LIST
X-FT-Domain-Ids: 17,18
{
"sourceType": "CONDITION",
"condition": {
"manufacturerIds": [4],
"modelIds": [311, 402],
"onlyOnline": true,
"domainIds": [17]
},
"pageNumbers": 1,
"pageSize": 2
}
{
"items": [
{"cpeId": 10234, "serial": "SAG1234567", "modelId": 311,
"manufacturer": "Sagemcom", "model": "F@st 5657", "domainId": 17,
"online": true, "protocol": "TR181", "supportedByAcs": true,
"firmware": "2.14.3"},
{"cpeId": 10235, "serial": "SAG1234568", "modelId": 402,
"manufacturer": "Sagemcom", "model": "F@st 3890", "domainId": 17,
"online": true, "protocol": "USP", "supportedByAcs": false,
"firmware": "1.9.0"}
],
"pageDetails": {"currentPage": [1], "pageItems": 2, "pageSize": 2,
"totalItems": 1545, "totalPages": 773},
"totalCount": 1545,
"onlineCount": 1545,
"unsupportedProtocolCount": 11,
"countsByModel": [
{"modelId": 311, "manufacturer": "Sagemcom", "model": "F@st 5657",
"deviceCount": 1043, "onlineCount": 1043},
{"modelId": 402, "manufacturer": "Sagemcom", "model": "F@st 3890",
"deviceCount": 502, "onlineCount": 502}
],
"notFoundSerials": [],
"duplicateSerials": []
}
Refusals:
| Rule | Status / FT code | Detail |
|---|---|---|
|
400 / |
Checked twice: by bean validation on the request record, and again in |
Any condition list whose entries are all null or blank |
400 / |
|
|
400 / |
The same rule one level up, in |
|
400 / |
|
|
400 / |
|
|
400 / |
|
Any of |
400 / |
|
More distinct serials than |
400 / |
|
|
400 / |
|
The selection resolving to more than |
400 / |
|
Identity contract not satisfied |
403 / |
See Identity Contract. The identity filter uses |
Multipart: no |
400 / |
|
Multipart: upload larger than |
413 / |
The message names the configured maximum in bytes, taken from the property and not from the exception, and omits the figure when neither source knows one — do not parse a number out of it. 413 and not 400: the upload was well formed and there is nothing to correct in it except its size. |
A part offered with a content type nothing accepts |
415 / |
Most often the optional |
The last three exist because `FccExceptionHandler’s catch-all would otherwise mask Spring’s own status and answer 500 for all of them — see the multipart status codes.
POST /FCC/devices/count
Exactly the figures of POST /FCC/devices/resolve with no page attached: same request record, same source types, same refusals, same selection limit.
Response (FccDeviceCountResponse): totalCount, onlineCount, unsupportedProtocolCount, countsByModel, notFoundSerials, duplicateSerials.
It exists so the wizard can keep the counters live while the operator is still changing the conditions, without carrying a page of up to 20000 devices over the wire on every keystroke. It is also where the operator finds out a fleet is too large — before they ever ask for its rows.
pageNumbers and pageSize are accepted on the body (it is the same record) and are simply not read.
POST /FCC/activate/devices/countByGroupConfigs
The impact figure of the activation screen, in Group Update’s shape — DEV-1381 #6, {"configs": […]} in and {"count": 0} out.
{"configs": [{"id": 268, "manufacturer": "ALCL", "model": "Nokia WiFi Beacon 4",
"sourceType": "All", "conditionId": null}]}
It is not POST /FCC/devices/count renamed; both exist. They answer two different questions:
/FCC/devices/count |
/FCC/activate/devices/countByGroupConfigs |
|---|---|
What an inline selection resolves to — the condition the wizard built, the serials somebody pasted, the file they uploaded. |
What saved configurations target, by manufacturer and model name. |
Answers the figures a selection needs: how many devices cannot run the test at all, which serials matched nothing, which were sent twice, the per-model counts the capability ceiling is checked against. |
Answers one number. |
Ours, from M3. The ticket does not mention it. |
The ticket’s, for the activation dialog. |
The count is a SUM over the configurations, not a union. Two configurations targeting the same device count it twice. That is GroupUpdateService.countDevicesByConfigs’ own arithmetic — a `mapToLong(…).sum() — and it is the figure the dialog is built around: how much work each configuration is, added up. The other reading, "how many distinct subscribers will be disturbed", is a different question and must not be served in place of this one.
What each source type counts:
sourceType |
Counted as |
|---|---|
|
Every device of the named manufacturer and model the caller can see. A blank name is left out rather than matched as an empty string, so a configuration that names neither is "everything visible". Within the caller’s domains, and |
|
|
|
The saved condition |
absent |
Refused, |
Group Update’s counter also accepts a model name with a protocol label appended — "Nokia WiFi Beacon 4 (TR-369)" — and strips it before matching. This service does not, because its own catalog (POST /FCC/manufacturerAndModelByTestType) never produces one: it returns the plain product_class.model with the protocol in a separate field. A labelled name would simply match nothing.
|
POST /FCC/activate/devices/countByGroupIds
The other half of the activation dialog’s impact figure — DEV-1381 #7, {"ids": [42, 43]} in and {"count": 0} out — and the simpler half.
It resolves nothing. A campaign’s fleet was resolved once, when it was saved, so fcc_test_device is the answer: a COUNT(*) over the campaigns asked about. That also makes this figure the truth about what will be dispatched rather than an estimate of it — the run will use those very rows. Group Update has to re-resolve instead, because a group keeps its children’s source types rather than a frozen list, which is why its own counter has four branches and this one has none.
A sum over the campaigns, like its sibling: a device in two campaigns' fleets is two pieces of work. Inside one campaign a double count is impossible — fcc_test_device_test_id_cpe_id_unique_idx forbids it.
|
An id the caller cannot see, or that does not exist, contributes nothing rather than failing the call. The domain predicate is inside the query, so the two cases are indistinguishable from outside — a count is not a place to disclose that a campaign exists in somebody else’s domain. Group Update is equally tolerant here: it skips an id it cannot find. A super-domain caller is served by a separate query with no domain predicate at all, not by an empty |
Duplicate ids are collapsed: asking about one campaign three times is one campaign’s worth of work.
PUT /FCC/devices/import
DEV-1381 #8, "the same as PUT /GroupUpdate/devices/import`": a `multipart/form-data upload of an .xml device inventory, resolved into the fleet it names.
Parts: a required file, plus the same optional request part the multipart /FCC/devices/resolve takes, read for paging and for onlyOnline / domainIds only.
Response: FccDeviceResolveResponse — the resolved page and every counter of POST /FCC/devices/resolve, because it is that resolution.
The accepted document is on the device resolution page; <serial> elements at any depth, everything else skipped, no DTDs.
|
It answers the resolved fleet and stores nothing. This is the one deliberate divergence from the endpoint it mirrors.
This service has no session to put it in, and must not grow one. Identity arrives as headers on every request (see Identity Contract), it runs as several replicas with no sticky routing, and an uploaded list living in one replica’s memory is a list the wizard loses on its next request. A store of that kind belongs in So the fleet travels in the response, the client holds it exactly as it holds a pasted serial list, and |
Two further differences from Group Update’s import, both following from what an FCC campaign is:
| Group Update | Here |
|---|---|
Takes |
Not read. A group update targets one product class; a compliance campaign is explicitly mixed — per-model counts in the catalog, a per-model capability ceiling, a fleet that legitimately spans manufacturers. Narrowing would make every serial of another model a |
A serial its lookup does not match is silently dropped from the stored list. |
Reported in |
Every refusal of POST /FCC/devices/resolve applies unchanged — the selection limit, the identity contract, the multipart 413 and 415 — plus the four of the XML document itself (not .xml, no file name, no <serial>, unreadable as XML), each a 400 / 9400.
ft-iot-webservice’s own Swagger text for `PUT /GroupUpdate/devices/import advertises CSV, Excel and JSON with per-format size limits. ImportService reads XML only and refuses anything whose file name does not end in .xml; the description is aspirational. The contract item says .xml, which is what both endpoints actually do.
|
Why One URL Serves Both JSON and an Upload
/FCC/devices/resolve and /FCC/devices/count each have two handler methods in FccDeviceController, on the same path and the same HTTP method, told apart by consumes:
Content-Type |
Handler | Body |
|---|---|---|
|
|
|
|
|
A required |
Spring dispatches on the content type, which is how the cross-repo contract’s single URL serves both.
The alternative — a second path such as /FCC/devices/resolveFile — would give the frontend two endpoints that must return the same counters, the same limit refusal and the same two serial reports, and the second one is the one that gets forgotten when a rule changes.
Here there is one service method behind both (FccDeviceResolveService.resolve), and the multipart handler does nothing but turn the upload into a request record.
|
The multipart variant forces
The upload IS the selection.
A file of serials combined with a model condition that excluded half of them would resolve to a fleet the operator never uploaded — and, worse, the serials that condition dropped would come back in
|
The file part’s accepted shapes and the upload size limit are on the device resolution page.
The Selection Limit Is Counted Before Any Row Is Read
A condition over a large installation can select hundreds of thousands of devices, and materialising them in order to find out is the one way these endpoints can take the service down.
So FccDeviceResolveService.read issues the COUNT(*) first and only then the row read:
selected = ftacsDeviceResolveDao.count(filter)
selected > ft.fcc.max-devices-per-test -> 400, nothing is read
The message names both numbers, and that is the point of it:
the selection resolves to 48210 devices; a test accepts at most 20000.
Narrow the conditions or split the fleet into several tests
The resolved figure is what the operator needs in order to narrow the conditions — "too many" without it is a dead end, because the operator has no way of telling whether they are over by ten devices or by a factor of three.
Three consequences worth knowing:
-
The limit is identical on
resolve, oncountand onPUT /FCC/item/details, because all three go through the same method. The count endpoint is where an operator normally meets it, which is the intended order: the fleet is refused before its rows are ever requested. -
The count is honest about the caller’s scope. On the one path where the domain narrowing happens in memory — a caller visible in more than 1000 domains —
COUNT(*)would count devices the caller may not see, soFtacsDeviceResolveDao.countfalls back to the size of the narrowed row set. That fallback is written out in the DAO rather than hidden, because a count that silently ignores the caller’s scope is the kind of wrong that never shows up as an error. -
The serial list has its own, earlier bound.
FccSerialListParser.normalizerefuses more thanft.fcc.max-devices-per-testdistinct serials before a statement is built at all — a 20001-line file cannot resolve to a legal fleet no matter what matches.
notFoundSerials and duplicateSerials Are Two Different Defects
Both are lists of serials, both are computed from the same single read, and they mean opposite things. Neither causes a refusal on the wizard endpoints: nothing is silently dropped, and nothing is silently refused either — the operator is told, while they are still choosing.
| Field | What it is |
|---|---|
|
Serials the caller asked for that no device visible to them carries. Matched case-insensitively and reported in the caller’s own spelling. Empty for |
|
Serials that matched more than one device. Every matching device is still in |
Why the case rule is not cosmetic. MySQL’s default collation compares serials case-insensitively while Oracle’s does not, so a serial typed in lower case resolves on one vendor and comes back "not found" on the other unless the comparison is made in Java — which is where FccDeviceResolveService.notFoundSerials makes it. Reporting the caller’s spelling rather than the matched one is what lets the operator find the offending line in the file they uploaded.
Why a duplicate is possible at all. ftacs.cpe is unique on (serial, product_class_id) and not on serial. One serial legitimately exists on two product classes — a re-badged unit, a model split, an import that created a second product class for the same hardware — and both rows are real CPEs the ACS can address and measure separately. Dropping one of them would shorten the fleet on the strength of a guess about which is the real device; refusing the whole selection would block a legitimate fleet over a fact of the ACS schema the operator cannot change. So both are returned, and the fact is surfaced: a compliance fleet that silently contains one household twice is a defect the operator has to see.
|
The wizard path reports; The same resolution runs on both, through
The asymmetry is the whole design. On the wizard the operator is still choosing, and the report is the point — a list with three typos in it is a list they can fix. At save time the operator has finished choosing, and a fleet assembled from a file they believe is complete must not be silently shortened: a campaign that stored 19 000 of the 20 500 serials in the uploaded list is a compliance report missing 1 500 subscribers, and nothing later in the run would reveal it — the run would finish, every device would report, and the numbers would all be internally consistent. A duplicate does not fail the save for the reason given above: they are two real CPEs, and refusing would block a legitimate fleet over something the operator cannot change. |
USP and Other Non-CWMP Devices Are Included and Flagged, Never Filtered Out
A device whose management protocol the ACS of v1 cannot drive is in items, in totalCount, in countsByModel, and — if the operator saves the selection — in the stored fleet.
It is flagged, not removed, on three fields:
| Field | Meaning |
|---|---|
|
The |
|
|
|
How many such devices the whole match holds. This is the number the wizard warns about. |
What the operator is actually being warned about.
supportedByAcs: false does not mean the device is rejected, or that the campaign will fail, or that anything will go wrong at save time.
It means this device is a legitimate member of the fleet whose measurements will simply never arrive: the ACS accepts the ACTIVATE, reports the device as SKIPPED with reason UNSUPPORTED on fccDeviceStateQueue (Device State Reasons), and the run closes with fewer measurements than devices.
Discovering that after a 24-hour window closed, in a run history, is the failure this flag exists to move forward to the moment the fleet is being chosen.
Filtering them out instead would be worse in both directions: the operator’s device counts would silently disagree with what they see in the ACS’s own device list, and a fleet selected by condition would quietly exclude devices the operator believes they selected.
The predicate is FccCpeProtocolEnum.isSupportedByAcs(), and it is the CWMP pair and nothing else in v1:
| Constant | cpe.protocol_id |
Supported |
|---|---|---|
|
|
Yes. CWMP with a TR-098 data model ( |
|
|
Yes. CWMP with a TR-181 data model ( |
|
|
No. |
DownloadDiagnostics / UploadDiagnostics / IPPing are defined for both CWMP data models, which is why TR181 belongs here as much as TR069 — this is the same predicate as the ACS’s own CpeProtocolEnum.isTrProtocol().
What decision R5 keeps out of v1 is USP (the WAN hook lives only in the TR-069 AOP and usp-core has no FCC references at all) together with the non-CWMP protocols; TR-181 was never excluded.
Two coercions, both deliberate and both in FccCpeProtocolEnum.fromProtocolId:
-
cpe.protocol_id IS NULLmeansTR069, not "unknown". The column was added long after the table and a legacy CWMP device still carries a null there. This is also what the ACS’s ownfromOrdinaldoes with a null. -
An id this build has not learned maps to
UNKNOWN, and is therefore reported unsupported. This deliberately diverges from both neighbours: the ACS’sfromOrdinalcoerces an unrecognised ordinal toTR069, andui-backend’s `ProtocolType.fromValuecoerces it (and a null) toNOT_SET. A device carrying a protocol id added to the ACS after this build is therefore shown to the operator as unsupported while the ACS may well still be able to drive it. Warning about a measurement we are not sure of is the intended failure direction; silently promising one is not.
|
Open cross-repo point, recorded here as such because it cannot be settled from this repository.
If Confirm against |
The protocol is also frozen with the fleet in fcc_test_device.protocol (M3), so the same question can be answered about a finished run without re-resolving against a live table — see fcc_test_device.
A Device With No Product Class Is a Member of the Fleet Like Any Other
cpe.product_class_id is nullable and the ACS schema carries no foreign keys, so a CPE can perfectly well exist with no product class, or with a dangling one.
Every join in the resolution read is a LEFT JOIN for that reason, and the consequence is worth stating in the contract rather than leaving to be discovered:
| How the device is named | What comes back |
|---|---|
By |
It is found and returned, with |
By any model-based condition — |
It is excluded, and silently, because a comparison against |
So such a device can only ever enter a fleet by id or by serial, never by narrowing. That asymmetry is the honest one: the operator picking "all Sagemcom F@st 5657" has said nothing at all about a device whose model the ACS does not record, and quietly including it would put hardware nobody chose into a compliance campaign.
|
It gets its own bucket in The alternative — dropping the null-model rows from the breakdown — would make the per-model counts sum to less than The null bucket is also what |
The full account of the join, and of what an INNER JOIN used to cost here, is in Every Join Is a LEFT Join; the bucket itself is the null model bucket.
The Capability Ceiling Is Refused, Not Clamped
FccTestValidator.validateConnectionCeilings runs on PUT /FCC/item/details, after the fleet has been resolved (FtacsResolvedDeviceRow.modelId is the key fcc_model_capability is keyed by) and before anything is written, so a refused campaign leaves no half-saved row.
The ceiling applied is the minimum non-null one across the distinct models of the fleet, because one command goes to all of them: a value the weakest model cannot honour is a value that model will clamp, refuse or ignore.
| Case | Ceiling |
|---|---|
A model with no |
Constrains nothing. Null is not zero — it is the absence of knowledge — and letting it lower the ceiling would refuse every campaign on every model nobody has entered yet, which is most of them. |
A row with a null on that direction |
Same: constrains nothing on that direction, while its other direction may still constrain. |
An empty fleet |
Not judged at all. Saving a campaign before picking its devices is a supported draft flow, and |
Neither |
Not judged. Nothing was asked for, so nothing can exceed. |
|
Clamping would be the friendlier behaviour and it is the wrong one here. The whole output of this product is a compliance report, and a report stating four connections for a run that was measured with two is a report nobody can defend in front of a regulator. Silently lowering the value would produce exactly that: the operator typed 4, the campaign stored 2, the report renders whatever the snapshot says, and the only record of the substitution is a log line nobody reads. The 400 names the value, the ceiling and the models that impose it:
"Lower it to two" is useless without "because of these models", because the operator’s real choice is usually neither — it is to split the fleet, and they cannot do that without knowing which models are holding it back. |
Removing a capability row later makes that model unconstrained again and changes no existing campaign: the ceilings were applied when each test was saved, and fcc_test_setting_speed already holds the frozen value — see The Save-Time Settings Chain, From the Caller’s Side.
PUT /FCC/item/details: The Type Is Stated Once, and diagnosticsType Is Why
The contract used to carry the diagnostics type in two places — configs[].diagnosticsType and configs[].settings[0].type — and a disagreement was a 400 naming both sides:
configs[].diagnosticsType and configs[].settings[0].type disagree: the configuration says
"Speed test" and its settings say "Latency". The contract states the type in both places and
they have to agree; neither can be chosen for you, because one of them is the diagnostic you
selected and the other is the parameters you typed.
That refusal was correct and useless. Picking a winner would have meant deciding for the operator which half of their screen was right — taking the configuration’s value runs a speed test with the latency parameters they typed, and taking the settings' own runs the diagnostic they did not select — so the only honest answer was to refuse a body whose single fault was contradicting itself, with a 400 naming two fields and nothing the caller could do about either.
configs[].diagnosticsType IS THEREFORE GONE, from the request and from the answer. configs[].settings[0].type is the one place the contract states the type, it is required, and it is what decides which half of the settings object is read, which settings row the configuration stores and what the ACTIVATE command is built from. A configuration without it is refused by position:
configs[2].settings[0].type is required: it is what says whether this configuration measures
"Speed test" or "Latency", and therefore which of its fields are read
One fact spelled twice can disagree; one fact spelled once cannot. The rule that remains is about the FIELDS alone — a settings object whose type says SPEED_TEST and which carries testHost is refused for carrying it, by name, which is The Settings Block Must Match the Type.
testMode: FILE_BASED and TIME_BASED, and What the Second One Costs
TR-143 defines two shapes of throughput test and this service runs both since contract 1.6.
FILE_BASED transfers a file and reports the window it took: the ACS arms DownloadURL (plus NumberOfConnections where the device has it), reads BOMTime, EOMTime and the byte counters, and the rate is total_bytes * 8 / (eom - bom).
TIME_BASED transfers for a stated number of seconds and reports a full-loading window inside it: the ACS additionally writes TimeBasedTestDuration — and TimeBasedTestMeasurementInterval when one was given — and reads back PeriodOfFullLoading, TestBytesReceivedUnderFullLoading / …Sent…, their Total… siblings, and the IncrementalResult.{i}. table. The rate is measured over the full-loading window and never over bom..eom, for the reason in Two Formulas, One Verdict.
The Two Fields
configs[].settings[0].timeBasedDurationSec is the duration, in seconds, and is required with TIME_BASED. timeBasedIntervalSec is the step of the IncrementalResult table, also in seconds, and is optional: omitting it lets the device choose its own granularity, and the ACS then leaves the parameter out of the SetParameterValues entirely. Both are refused on a FILE_BASED block rather than ignored — see the refusal table above for all five rules.
The Sec suffix is the convention this contract already uses for wanSampleGapSec, wanRetryIntervalSec, wanStepTimeoutSec and diagTimeoutSec, and it is worth the noise here: activation.singleTestDuration beside it is in MINUTES, and the one cross-field rule between them compares the two.
It Is Per-Model Opt-In, and the Reason Is the Device
The time-based leaves are a TR-181 Device:2 addition and are absent on much of the TR-098 population. That matters more than it sounds: a SetParameterValues carrying one parameter the device does not implement is rejected as a whole, so an unguarded time-based request would not run a file-based test by accident — it would not run at all.
The ACS therefore probes TimeBasedTestDuration on the device before arming it (the same CpeParameterNameCache guard NumberOfConnections has used since M3) and refuses to build the diagnostic at all when the parameter is unknown, which the tick turns into ERROR(DEVICE_FAULT) for that device. Silently falling back to a file-based transfer would publish a measurement of something nobody asked for.
This service refuses earlier and more usefully, at save time: fcc_model_capability.supports_time_based must be recorded for every model of the configuration, and a model with no row counts as not supporting it. So the operator finds out while looking at the form, not hours later in a run history full of DEVICE_FAULT. The practical consequence is that the capability catalog has to be populated before the feature is useful — POST /FCC/manufacturerAndModelByTestType already serves supportsTimeBased per model, so a UI can grey the choice out rather than let it be chosen and refused.
speedTierMode Is Provenance, Not a Rule
"default" means the tier was taken from the model’s preset; "manual" means the operator typed it. The tier value is what the ACS is given either way, and this service still does not check a tier against the preset list POST /FCC/manufacturerAndModelByTestType serves — a subscriber’s provisioned tier is whatever their contract says, and the presets are a convenience (Tiers Are Served, Not Hard-Coded in the UI).
What the field buys is the screen: a campaign re-opened with manual shows the figure in its input, one with default shows the preset it was picked from. Without it a UI cannot tell a typed 97000 from a preset that happens to be 97000, and has to guess which control to render.
It belongs to both kinds, because a latency campaign carries a speedTier too — the rate the line is provisioned at, which latencySuccessMs is judged against. Both settings tables have the column (speed_tier_mode VARCHAR(16)), nullable and never back-filled: a campaign saved before it records no provenance, and writing default into it would claim the tier came from a preset nobody can name.
The constants are DEFAULT / MANUAL and the wire values are default / manual. That is the second and last @JsonProperty in this service, and the only unavoidable one: lower-case default is a Java keyword and cannot be a constant name at all. The first is FccDiagnosticsTypeEnum, where "Speed test" carries a space. All-caps rather than Default / Manual, although both are legal: the value is stored (speed_tier_mode holds the constant name, like every enum column in this schema) and it travels in the XML document, where a CamelCase constant was a round-trip bug — the writer emitted Manual and the reader upper-cased it before looking it up, so a document this service had just exported was refused on import. FccConfigRecordFields.parseEnum now matches constant names case-insensitively as well, but a stored name that follows the convention is the first line of that defence.
|
PUT /FCC/item/details: Several Configurations, and Whose Limit Bounds Them
configs[] is an array in the contract and it accepts several elements, each with its own settings and its own fleet. Every one of them becomes its own ACS activation on every run.
THE OLD LIMIT WAS ONE ELEMENT, AND THE REASON GIVEN FOR IT WAS TRUE AND WAS NOT A REASON. It went:
-
an
FccCommandDtocarries oneFccActivationConfigDtoand one device list per activation; -
fcc_sa_activationis unique on bothactivation_idandidempotency_key, andFccActivationService.persistActivationtreats a duplicate as a retry of a previous attempt — it takes the existing row and carries on; -
so a second command for one run would be swallowed as a retry, and storing two configurations while dispatching one is a campaign that measures half of what the operator configured.
Every sentence of that holds. What it bounds is one command per activation, which one activation per CONFIGURATION satisfies exactly: N configurations are N activations, each with its own activation_id, its own idempotency_key and its own command_seq, so nothing is a retry of anything and nothing is stale. It was the one-activation-per-run assumption of this service, not the ACS, that made a second configuration unrunnable. The ACS needed no change at all — FccSaTickService already loops over the activations of a campaign, and fcc_sa_device is unique on (activation_id, cpe_id), so a device may sit in two concurrent activations.
WHAT DOES BOUND THE ARRAY IS THIS SAVE. ft.fcc.max-configs-per-test (ten by default) is a limit on the request, because N configurations resolve N fleets of up to ft.fcc.max-devices-per-test devices each, in one transaction:
configs carries 11 configurations; at most 10 are accepted (ft.fcc.max-configs-per-test). The
limit is this save's and not the ACS's - each configuration becomes its own activation - but
every one of them resolves a fleet of up to 20000 devices in the same transaction
MIXED KINDS IN ONE CAMPAIGN ARE ALLOWED, and so are OVERLAPPING FLEETS:
-
a speed configuration beside a latency one is exactly what one legacy
update_groupcarrying a 103 task and a 104 task is — see Legacy Import, which is where the shape comes from; -
a device named by two configurations is measured by both. It then sits in two concurrent ACS activations, and the two measurements may distort each other’s WAN sampling — which is a judgement about the fleet the operator is looking at while they select it, not a rule this endpoint can make for them.
There are therefore no cross-configuration rules at all. Every rule this endpoint applies is about ONE configuration, and names it by its position in the array: configs[2].settings is required, configs[1] accepts at most 20000 devices. "configs[].settings is required" named nothing while a campaign could only have one.
The one limit that does remain is configs[].settings at a single object: one FccDiagnosticsSettingRequest already carries both directions of a speed test and both ends of a latency one, and the ACS activation carries one set. A campaign that measures two different things carries two configurations.
The Set of Configurations Is Frozen With the Fleets
The fleet freeze is per configuration once activation_count > 0, and the set of configurations is frozen with it. Moving a device from the first configuration to the second changes what both of them measured without changing the campaign’s device list at all, which a campaign-wide comparison would have accepted; and adding or removing a configuration would make a run dispatch a number of activations the finished runs did not have. Both are 409s naming what would change:
the device set of test id=7 is frozen: it has been activated 2 time(s), and the fleet of
configuration(s) 0, 1 would change. To run a different fleet with this configuration, export
the campaign (PUT /FCC/item/export) and import the file back (PUT /FCC/item/import): that
answers it as a new, unsaved campaign whose fleet you can change before saving
PUT /FCC/item/details: Where the Fleet Comes From
configs[].sourceType decides what is read, and each branch lands in the same FccDeviceResolveService path the wizard’s own counter used — so what is stored is what the operator was shown.
sourceType |
What is resolved |
|---|---|
|
Every device of the named |
|
|
|
The same as |
|
The saved condition |
|
The exclusions are how "every device of this model except these four" is expressed — the other half of a select-all checkbox, which has to work without turning the choice into a list of four thousand ids. They are subtracted in That has a consequence worth stating plainly: a selection of 25 000 devices with 6 000 excluded is refused even though 19 000 would be legal. The 25 000 have to be read in order to be subtracted from, and reading an unbounded selection is the one way this endpoint can take the service down (The Selection Limit Is Counted Before Any Row Is Read). The refusal names both numbers, so the operator narrows the selection rather than the exclusions. An exclusion that matches nothing is not an error — the opposite of the rule for |
sourceType=Condition: The Subset of the Portal’s Conditions This Service Translates
conditionId is a saved view of the portal’s device list — iotw_view with its criteria in iotw_view_column_condition, both in ui-backend’s own schema.
This service holds `SELECT on exactly those two tables (the grant), writes nothing there and reads nothing else; FccSavedConditionService answers three questions around the translation — is the feature available, may this caller see this condition, and what is it called — and FccConditionTranslator turns the criteria into one parenthesised SQL fragment ANDed onto the device query.
|
The condition is evaluated once, at save time, and the fleet is stored as cpe ids. A condition re-read at activation time would dispatch a diagnostic to whatever it selects then, which is not the fleet anybody approved — see the frozen snapshot. The |
Why a subset, and not all of it
The portal evaluates a condition with about fifteen hundred lines of JPA Criteria across ViewService, DeviceViewUtil, QueryViewHelper and FieldPath: 23 operators, 47 column keys, correlated subqueries over cpe_parameter with a startup-resolved parameter-name map, a table of 26 custom device fields, protocol-labelled model names, and date operators whose meaning depends on the caller’s client type and time zone.
Reimplementing all of that here would be a second implementation of one rule, and the failure mode of any divergence is silent: the device list shows four thousand devices, the campaign measures three thousand eight hundred, and nobody finds out until a compliance report is short.
So this service translates only what it can prove it translates identically, and refuses the rest with a 400 that names the column key and the operator and says what to do instead — resolve the condition on the device list and send the devices as a list or a file, a flow that already exists.
What is translated
| Column key | SQL | Operators |
|---|---|---|
|
|
|
|
|
the same six |
|
|
|
|
|
|
|
|
|
|
|
|
Everything else is refused by name: oui, created, updated, domainName, protocolType, the 26 custom device fields, the two parameter keys, every date operator, and deviceId — which ColumnFilter has no entry for, so the portal cannot build a condition on it at all.
Five things that are matched character for character, and are easy to get wrong
Each was read off the portal’s own code rather than inferred from the screen, and each is pinned by a test in FccConditionTranslatorTest:
-
manufacturerandmodelare the product group's columns.FieldPathpoints atproductClass.productGroup.manufacturerNameand.model, andProductClassGroupEntitymaps the field calledmodelonto the column calledproduct_class. There is noproduct_class_group.model. This service’s own wizard filter comparesproduct_class.modeland the resolved manufacturer display name with a fallback to the group — the right answer for something an operator picks from our own drop-downs, and the wrong one here. -
statususesOn/Offwith no value, neverEqual "true". The column’s filter type isOnOrOffand the portal rendersisTrue/isFalse; both exclude NULL, so a device whoseis_onlinewas never written is neither on nor off.Offis therefore= 0and not= 0 OR IS NULL. -
Equalwith no value is a plainIS NULL, andIsNullis blank-or-null. The portal answerscb.isNullfor the first andisBlankfor the second, so an empty string incpe.firmwaresatisfiesIsNulland notEqual "".NotEqualwith no value is "always true". Three rules that look like one. -
Every string comparison is
LOWER(column)against a lower-cased value, trimmed only when it has text (QueryViewHelpertrims inside itshasStringValuebranch, so a value of spaces reachesLIKEuntrimmed), andLikeis a contains with the value not escaped — a%an operator typed into a saved condition is a wildcard there. A stringNotEqualis null-tolerant (<> value OR IS NULL); a domainNotEqualis not. -
domainIddoes not go through the generic path at all.ViewService.getPredicateFromDomainhandles it:Equalon the super domain (0or-1) matches0,-1and NULL;InHierarchyon the super domain matches everything (cb.and()with no arguments);InHierarchyon a real domain is the branch under it, read fromispby name with the statementDomainRepository.getDomains(id)transliterated. A domain deleted from the ACS since the condition was saved selects nothing — which is not the same as selecting everything.
IsNotNull on domainId is the one deliberate departure from the portal: getPredicateFromDomain’s `default arm treats it, and Like, InList and the date operators, as Equal on the parsed value, and throws on a value that is not a number. Reproducing that would be reproducing a bug, and silently evaluating "domain is not null" as "domain = 5" is exactly the divergence this translator exists to avoid.
Structure: a flat conjunction, or a refusal
A condition is a tree (parent_id), and each node carries its own And/Or. The portal’s combinator turns a sibling list into runs — a(And) b(And) c(Or) d(And) is (parent AND a AND b) OR (parent AND c AND d) — which is faithfully reproducible and is also exactly the kind of thing that is reproduced subtly wrong once and then trusted for years.
This service therefore accepts only the structure it can state in one line — every node at the top level, every node And — and refuses nesting or any Or by criterion id. That is the shape an FCC campaign actually names ("this manufacturer and this model and online").
An empty condition is refused, and that is the most important refusal here: a view row that exists with no criteria under it selects every device the caller can see. It is almost always a condition whose rows were deleted, and "every device in your domains" is not something anybody picked from a dropdown.
What the condition cannot do
-
It can only narrow. The fragment is ANDed into the same
WHEREas the caller’s own domain predicate fromFccDomainScope, so a condition belonging to another domain — or one naming domains the caller cannot see — cannot widen the selection. -
It cannot inject SQL. Column names come from an allow list keyed by the portal’s own column key, operators from a closed switch, and every value is a bound parameter named
cond0..N, a prefix no other clause of the device query uses. -
A condition of a domain the caller’s scope does not reach is the same 404 as one that does not exist — a distinct "it exists but is not yours" would confirm that it does. Conditions of the super domain, and those with no domain at all, are the portal’s shared views and are visible to everyone.
When the schema is unreachable
The iotw datasource is lazy, so the first condition read is also the first connection attempt.
-
On the save and count paths, an unreachable schema or a missing grant is a 400 naming the condition, never an empty fleet: a campaign saved with no devices where the operator selected thousands is the failure this source type exists to avoid.
-
On the details read, the same conditions answer a
nullconditionNameand a readable campaign. -
ft.fcc.iotw.enabled=falserefuses everyconditionIdwith a message that says so and names the two grants. The other three source types are unaffected, which is what makes an installation without the grant fully functional. -
ft.fcc.iotw.schema-check.enabled(defaulttrue) checks both tables at startup and logs a warning rather than failing — see the startup check. It is the one schema check in this service that does not abort startup, because a missing grant costs one device source type and not the service.
PUT /FCC/item/details: Reactivation Is a Rule, Not a Count
DEV-1381 #10 replaced reactivationDays with a recurrence rule:
| Field | Meaning |
|---|---|
|
|
|
Days between two runs. Absent means 1, which is the 24-hour constant the planner had hard-coded, so every campaign stored before this field keeps its cadence. Range 1..365. |
|
The anchor the slots are counted from: run |
|
|
|
How many runs the campaign owes after the first. This is the existing |
The column did not follow the wire: renaming reactivation_days would move a migration, the state machine (FccTestTransitionContext.hasMoreReactivations()), the planner, the legacy ETL and five documentation pages in order to change a string no caller sees. It is the same mapping the speed settings already have — downloadUrl on the wire, url_dl in the schema — declared once on the entity.
repeatEvery and startsOn are two new columns (fcc_test.reactivation_repeat_every, NOT NULL DEFAULT 1, and fcc_test.reactivation_starts_on, nullable), so no campaign already stored changes behaviour.
An absent block is a one-shot campaign, and an update that omits it clears a rule the campaign had — an operator who removed the repetition on the screen; keeping yesterday’s rule would make the campaign repeat after they stopped asking it to, which is the reading scheduled_at already has for an absent activation.
The Two Administration Tables Are Not Authorized the Same Way
FccAdminController carries eight new operations, four over each table.
They are authorized differently, and the difference is the data rather than the screen.
| Table | Read | Write / delete | Because |
|---|---|---|---|
|
Any caller |
|
The table is installation-global: it is keyed by an ACS model id and carries no domain at all, because a connection ceiling is a property of the hardware and constrains every domain’s campaigns alike. |
|
Confined to the caller’s visible domains |
Confined to the caller’s visible domains |
The rows are domain-scoped: an operator changing their own domain’s default test server affects only their own future campaigns. |
|
The Every caller Without this one rule, an operator responsible for a single domain could raise — or, far worse, lower — the connection ceiling for every other operator of the installation, silently changing what their campaigns are allowed to measure with, with the refusal surfacing on somebody else’s campaign as a 400 naming a number they never typed. A super-domain caller is the closest thing to an installation administrator this contract can express, so that is what the write demands. Reads are deliberately open to any caller: the same ceilings are already visible through the wizard catalog ( |
domain-setting: 404 on Read, 403 on Write
For fcc_domain_setting a row outside the caller’s visible domains reads as 404 and writes as 403.
The asymmetry is deliberate.
| Operation | Status | Reasoning |
|---|---|---|
|
404 |
A read is an id the caller may simply have guessed. Answering 403 would confirm that a row exists for a domain they cannot see — exactly the reasoning that makes a foreign campaign a 404 rather than a 403 (LIST vs. ALL Filtering). A domain the caller can see but which has no row is the same 404, and honestly so: "this domain publishes no overrides" and "there is no record to show you" are the same fact. |
|
403 |
A write names its domain explicitly in the body. The caller already knows which domain they are asking about, so there is nothing left to conceal, and a 404 would only be confusing — it would read as "that domain does not exist" and send an operator looking for a bug in the wrong place. |
|
200, with the refusal in that id’s result item |
The bulk envelope wins here, exactly as it does for |
fcc_model_capability has no such split, because it has no domain to conceal: POST …/model-capability/details answers 404 purely for a model nobody has entered a row for.
That is not a contradiction of the rule that a model with no row is unconstrained: "what may this model be asked to do" has an answer for every model in the installation, while "show me the record somebody entered" has none when nobody entered one.
Answering with an empty row would leave the edit screen unable to tell "no limits recorded" from "limits recorded as none", and the operator would not know whether they were creating or editing.
/FCC/admin/model-capability
Four operations on the installation-global capability table. See fcc_model_capability for the schema and The Two Administration Tables Are Not Authorized the Same Way for the access rule.
| Operation | Request | Response |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
FccModelCapabilityItem: modelId, manufacturer, model, connectionsDlMax, connectionsUlMax, supportsTimeBased, created, updated.
The manufacturer and model names are read from the ACS, for the ids of the page only — never for the whole table, which is what a join would amount to across two schemas that may be two database instances. A capability row whose model has since been removed from the ACS therefore renders with null names and keeps its row: it is the operator’s data, the ACS is read through a read-only account and may legitimately be mid-migration, and destroying stored data because one read came back short is not a trade this service gets to make.
FccModelCapabilityUpsertRequest:
| Field | Type | Notes |
|---|---|---|
|
Integer, required |
Must exist in |
|
Integer |
1..4 when given, judged by the very method |
|
Integer |
The same for the upload direction. |
|
Boolean |
Stored, and acted on since contract 1.6: setting it |
There is no row id and no create/update flag: the unique index makes one row per model the only possible state, so the model id is the key, and a caller that had to know whether the row already exists in order to save it would be doing the service’s job.
Removing a row makes the model unconstrained again — it does not forbid it — and it changes no existing campaign: the ceilings were applied when each test was saved.
The scope check is a whole-request refusal: it runs once, before the loop, and answers 403 for the request rather than a refusal per model. A caller who may not administer capabilities is not being told, forty times over, which model ids exist.
Both Admin Deletes Are Per-Item Transactions, Not One
Neither delete method is @Transactional. Each id is deleted in a transaction of its own, by FccModelCapabilityDeleteHandler / FccDomainSettingDeleteHandler, and the bulk loop only translates the outcome into a result item.
A single transaction around the loop looks harmless and is not.
The only refusal is "there is no such row", decided by a read — so a shared transaction seems safe.
But the repository delete and FccAuditService.record are themselves advised and join the caller’s transaction, so one item failing for any unforeseen reason marks the shared transaction rollback-only; the per-item catch then swallows the exception, the method returns a fully populated per-item response, and the commit throws UnexpectedRollbackException.
The caller gets a 500, every successful delete of the batch is discarded, and the per-item contract this endpoint documents turns out not to hold on the one occasion it mattered.
The handlers are separate beans and not private methods for the reason FccTestDeleteHandler already spells out for campaigns: a self-invoked @Transactional method never reaches the Spring proxy, so a loop and its unit of work inside the same class share one transaction whatever the annotation says. REQUIRES_NEW on a self-invoked method would change nothing, since the proxy is bypassed either way.
A missing row is now an FccNotFoundException raised from the handler and caught by the loop, rather than a result item built inside it, so each handler call has exactly one outcome: the row is gone and audited, or nothing happened at all.
Every write and every delete appends one fcc_audit row under entity_type = 'MODEL_CAPABILITY', with entity_id the model id (the business key an auditor reading the trail after a delete actually holds) and a null domain_id: the change belongs to no domain, and recording the actor’s own would suggest it was scoped to it.
Both Admin Upserts Retry Once on a Unique-Constraint Collision
PUT /FCC/admin/model-capability and PUT /FCC/admin/domain-setting are both create-or-update keyed on a unique index — model_id and domain_id respectively — and both implement it as a find-then-save.
That shape has exactly one race, and it is worth naming precisely, because the fix looks like defensive programming and is not:
-
Two operators save the same model at the same moment. Both
findByModelIdreturn empty. -
Both take the create branch and both
INSERT. -
The unique index does its job, and the loser gets a
DataIntegrityViolationException— which, left alone, is an HTTP 500 for a save that was perfectly valid and that the caller has no way to distinguish from a broken service.
Nothing is corrupted by this. The index is precisely what makes the failure happen rather than letting a second row through, and the loser’s second attempt finds the row the winner just created and takes the UPDATE branch, which cannot violate it.
So one retry is the entire cure, and the two @Transactional writes now live in beans of their own — FccModelCapabilityUpsertHandler and FccDomainSettingUpsertHandler — with the admin service catching the violation and calling store a second time.
|
The retry has to be a second call through the bean, and the handler cannot be a private method. The failed Self-invoking a A consequence of the same rollback: the abandoned attempt leaves no |
A second failure is deliberately not retried. A violation that survives an UPDATE is not contention — it is a constraint nobody expected — and it belongs in the 500 rather than in a loop that hides it. This is a single-operator administration screen; the race being defended against is two browser tabs, not write load, and a retry loop here would only ever convert a real schema surprise into a hang.
The loser’s attempt is logged at WARN ("… upsert raced modelId=… : the row was created concurrently, retrying once as an update"), so a collision that is somehow not two tabs is visible rather than silently absorbed.
Note that this moved the validation out of a transaction: requireAllScope, FtacsCatalogDao.modelExists and the range checks all run before store is called, on no transaction at all. That is the right side of the line — none of them writes, and modelExists reads the read-only ftacs datasource, which was never part of this transaction in the first place.
/FCC/admin/domain-setting
Four operations on the per-domain middle level of the settings chain. See fcc_domain_setting for the schema, The Settings Resolution Chain for what the values do, and domain-setting: 404 on Read, 403 on Write for the two statuses.
| Operation | Request | Response |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
FccDomainSettingItem / FccDomainSettingUpsertRequest carry the same seven values: urlDl, urlUl, thresholdDl, thresholdUl, speedSuccessPercent, latencySuccessMs, latencyHost.
Every field is nullable and null means "this domain publishes no opinion on it" — the test then falls through to the global FT Configs default, or to nothing at all for urlDl, urlUl and latencyHost, which have no level below the domain.
A PUT states the whole row. A field sent as null clears that override and sends the field back to its global default, rather than leaving the stored value alone.
There is no partial update, because the screen this serves renders every field, and a caller that could omit one would have no way of clearing it.
The field rules are the test-level ones, called as the very static methods of FccTestValidator that PUT /FCC/item/details uses:
| Field | Rule |
|---|---|
|
|
|
|
|
Stored as given. It is the ISP’s default |
One rule, one implementation: a default that the test validator would reject must not be storable as a default, or the refusal would surface later on every campaign of that domain, naming a value the operator never typed.
This is now a belt-and-braces gate rather than the only one: since PUT /FCC/item/details validates the resolved record (The Settings Are Resolved Before They Are Validated), a malformed per-domain URL is also caught on every campaign that inherits it. Both are kept — the admin screen is where an administrator can actually fix it, and the campaign gate is what stops a row stored before this rule existed from reaching a run.
The delete is a per-item transaction, exactly like its model-capability sibling — see Both Admin Deletes Are Per-Item Transactions, Not One. A domain outside the caller’s scope is still checked in the loop rather than in the handler: it is a read that needs no transaction, and isVisible is the one visibility rule this class applies to reads, writes and deletes alike.
The upsert is likewise the model-capability sibling’s shape: the row is written by FccDomainSettingUpsertHandler in a transaction of its own, which the service retries once if another writer created the row for this domain in between — see Both Admin Upserts Retry Once on a Unique-Constraint Collision for why one retry is the whole cure and why it cannot be done inside the original transaction.
|
Editing or deleting a row changes no existing campaign. The chain is walked once, when a test is saved (The Settings Resolution Chain). A campaign that already froze the old URL keeps measuring against it for the rest of its reactivations, which is the behaviour a regulatory report depends on — and deleting a row makes tests saved afterwards fall through to the global defaults while changing nothing about the campaigns that already froze their values. This is also why the audit trail matters more here than for a campaign, not less: a campaign that was tampered with still carries its own settings and its own run history, whereas a change to |
The Save-Time Settings Chain, From the Caller’s Side
PUT /FCC/item/details resolves absent values through three levels, once, at save time, and stores the result:
the request's own value -> fcc_domain_setting (this test's domain) -> global FT Configs
first non-null wins, reading left to right
From an API caller’s point of view that has three visible consequences:
-
POST /FCC/item/detailsreads back the resolved values, not what was sent. A body that omittedspeed.downloadUrlcomes back carrying the domain’s URL. That is the stored campaign, and it is what the run will use. -
A blank string is treated as absent, not as a value.
"urlDl": ""— the normal shape of an FT UI request for a field the operator left empty — falls through to the domain default. A strict first-non-null chain would let that empty string beat the default, so the per-domain URL would never apply for exactly the caller it exists for, and the campaign would failvalidateRunnablelater with no indication that a default was available. When no level has a value the field is stored and read back asnull, never as"". -
The rules are applied to the resolved values, not to what was sent. A URL inherited from the domain is judged by the same
requireDiagnosticUrlWhenPresentan operator’s own would be, and alatency.testHostsupplied only by the domain satisfies the requirement. See The Settings Are Resolved Before They Are Validated. -
Editing a domain default afterwards does not reach the campaign. See the note under /FCC/admin/domain-setting.
Which fields have which levels is in The Settings Resolution Chain; that is also where the deliberate absence of any walk up the domain tree is argued.
Endpoints Implemented in M4
M4 ships in two halves.
The reporting half is four endpoints, all reads, all behind FccReportController: POST /FCC/reports/list, POST /FCC/reports/results, POST /FCC/reports/generate and POST /FCC/reports/export. Nothing there creates, updates or deletes anything — there is no report entity in ftfcc, and the reporting half added no table at all (ftfcc Schema).
The legacy import half is three more, behind FccLegacyImportController, under /FCC/admin/legacy-import/**: they drive the ETL that produces the LEGACY_ETL rows the four reporting endpoints already know how to read. They are documented at the end of this section, in The Legacy Import Endpoints, and they are authorized by a stricter rule than anything else in this service.
|
One filter resolver, |
The Unit of a Report Is a Run, Not a Campaign
Every one of the four endpoints answers in terms of fcc_activation rows, never fcc_test rows. A campaign with reactivationDays = 7 produces seven runs of one configuration against one fleet over a week, and reactivation 3 has to be distinguishable from reactivation 1 — which is precisely why fcc_activation exists as an entity of its own (fcc_activation). Imported measurements arrive under the synthetic runs the M4 legacy ETL creates, so they line up in the same list as everything else rather than needing one of their own.
Rules Shared by All Four Endpoints
| Rule | Why |
|---|---|
|
The WAN was busy above the configured threshold, the iteration carries no usable figure, and counting it as a breach would overstate non-compliance — the same regulatory reasoning The Pass/Fail Formula already applies at write time. Every counter and every filter in this section treats it as a fourth, independent value alongside |
Speed is reported in Kbps, never Mbps |
Every tier, every threshold and every field of the inter-service contract is in Kbps; converting on the way out would put a rounding step between the stored measurement and the figure a compliance report quotes. Rendering Mbps, if a UI wants to, is the client’s decision, made once at the edge — not this service’s. |
|
A measurement that arrived after a run’s |
Thresholds in every row come from the |
|
Two |
A measurement is |
All four answer 503 / |
|
POST /FCC/reports/list
One page of runs, each with its five result counters. FccReportController.list → FccReportService.list.
Request (FccReportListRequest):
| Field | Type | Notes |
|---|---|---|
|
|
See [report-filters]. |
|
|
Whitelisted by |
|
Integer, 1-based |
Defaults to 1 if absent or |
|
Integer |
Defaults to 25, capped at 500. |
FccReportFilters, shared by all four endpoints:
| Field | Type | Notes |
|---|---|---|
|
|
Narrows within the caller’s own visible scope and can only narrow it, exactly as The Empty Intersection Selects Nothing, Never Everything describes for the other bodies that carry this field — naming a domain the caller cannot see selects nothing at all, whether or not |
|
Long |
One campaign. Checked through the same access resolver every other endpoint uses; outside the caller’s visible domains it is 404, never 403 — see LIST vs. ALL Filtering. |
|
Long |
One run. Must belong to |
|
|
Reads the one table when given, the |
|
String, |
Resolved through the frozen fleet |
|
Instant |
Bounds on |
|
|
The measurement dictionary. See Rules Shared by All Four Endpoints. |
|
|
|
|
Boolean |
|
|
Integer, String |
One device, resolved to the serial(s) it was frozen under in |
|
|
Response (FccPage<FccReportListItem>):
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Translated from the run’s own |
|
Instant |
Four of these five are nullable — see [report-list-default-sort]. |
|
Integer |
The size of the fleet this run was dispatched with, not the number of devices that produced a measurement: an unsupported or offline CPE is counted here and appears in no result row. |
|
From the |
|
|
Long |
One grouped |
|
The default sort is Four of the others — |
POST /FCC/reports/results
One page of measurements — the individual iterations behind a report, with a real total over the whole filtered set. FccReportController.results → FccReportService.results → FccResultQueryDao.findPage / .count.
Request (FccReportResultsRequest): filters ([report-filters], where afterStop now genuinely narrows rows), pageNumbers (1-based, default 1), pageSize (default 50, capped at 1000 — deliberately its own, higher ceiling than the run list’s 500, because a measurement row is smaller and the grid shows far more of them at once).
|
There is no The order is fixed at |
Response (FccPage<FccReportResultItem>): the common block (type, fccTestId, activationId, iteration, domainId, serial, hubbId, direction, url, tierKbps, startedAt, finishedAt, status, description, wanUsageDlKbps, wanUsageUlKbps, afterStop, source, legacyUgId, legacyResultId, created) plus exactly one of:
| Block | Present when | Fields |
|---|---|---|
|
|
|
|
|
|
The other block is null — which is what a UNION ALL of the two result tables means, see The UNION ALL Is Generated, Never Hand-Written Twice.
A campaign or run the caller cannot see answers 404, never 403 — a 403 would confirm it exists in somebody else’s domain, the same reasoning as every other endpoint of this service (LIST vs. ALL Filtering).
POST /FCC/reports/generate
The header figures of a report, over the same filter POST /FCC/reports/results pages through. FccReportController.generate → FccReportService.generate → FccResultQueryDao.aggregate — one statement over the UNION ALL, not two summed in Java, because uniqExact(serial) / uniqExact(activation_id) are distinct counts and cannot be added per table without double-counting a device that appears in both a speed and a latency campaign.
Request (FccReportGenerateRequest): {"filters": {…}} — the same FccReportFilters block, nothing else.
Response (FccReportSummary):
| Field | Type | Notes |
|---|---|---|
|
Long |
|
|
Long |
Distinct serials / runs across the whole selection, over the union rather than per table, for the reason above. |
|
Instant |
|
|
Double |
In Kbps — see Rules Shared by All Four Endpoints. |
|
Double |
Average of the per-iteration averages, over the iterations that have one: a ping that lost every packet contributes no value and does not pull this figure toward zero. |
|
Integer |
|
This operation creates nothing and stores nothing, and repeating it is free and always safe. There is no report entity anywhere in The verb changed from
The caveat that used to stand here is resolved, and was resolved in the contract rather than locally. Through M4 this section warned that the contract named the path and the verb but not the behaviour, so "compute and return an aggregate" was this service’s own reading of it. Contract 1.5.2 closed that gap by stating the verb and the behaviour in the same line, so |
POST /FCC/reports/export
The same rows POST /FCC/reports/results pages through, in a file, streamed: FccReportController.export → FccReportExportService.prepare / .write → FccResultQueryDao.stream.
Request (FccReportExportRequest): filters ([report-filters], no paging — an export is the whole selection, however large), format (XLSX \| CSV, required). The format is a field of the body, not a path extension or a query parameter, so both formats go through the identical filter resolution, access check and storage gate; a second path per format would be a second place for any of the three to be forgotten when a rule changes.
Response: 200, Content-Type one of:
format |
Content-Type |
|---|---|
|
|
|
|
plus Content-Disposition: attachment; filename="fcc-report[-run-<id>\|-test-<id>]-<yyyyMMdd-HHmmss>.<ext>", and a body that is a raw byte stream (StreamingResponseBody) — there is no JSON envelope and no FccPage.
|
Nothing is accumulated anywhere — not a page, not a list, not a temporary file. Rows travel from ClickHouse through a |
|
|
|
Every refusal happens before the first byte. The result-store gate is checked first: Once the status line is on the wire, nothing can become a 4xx or a 503 any more — a bad body is 400, an invisible campaign or run is 404, an unavailable or schema-behind result store is 503/9503, all decided in |
CSV is RFC 4180 in UTF-8 with no byte order mark — a BOM would help one double-clicking spreadsheet reader guess the encoding and would just as reliably corrupt the first column heading for every program that parses the file programmatically, which is what a compliance extract is mostly fed to; the encoding is stated in Content-Type instead. description is untrusted device text on both formats: CSV quotes per RFC 4180 and doubles embedded quotes, while XLSX escapes the five markup characters and drops the code points XML 1.0 forbids while keeping well-formed surrogate pairs whole.
Set a client timeout of at least five minutes. A large regulatory extract legitimately takes minutes to stream; the server side of the same requirement is spring.mvc.async.request-timeout (FCC_EXPORT_REQUEST_TIMEOUT, default 10m) — see Configuration Reference and the export-truncated row of Troubleshooting.
Status codes, common to all four endpoints of this section:
| Status | When |
|---|---|
200 |
Request answered. For |
400 |
The body fails bean validation, or names an unsupported enum constant on the wire — including |
404 |
|
503 ( |
The result store cannot be read — unreachable or schema-behind, told apart by |
The Legacy Import Endpoints
Three endpoints behind FccLegacyImportController, the second half of M4: POST /FCC/admin/legacy-import/start, /status and /stop.
They drive the ETL that replays ftacs.ug_fcc_speed_result and ug_fcc_latency_result into ClickHouse — what it reconstructs, and what it refuses to recompute, is Legacy FCC Import; the operator’s sequence is The Legacy Import Runbook.
All three are POST, including status, which reads. That is the FT internal FE-BE style this whole service is written in (POST /{controller}/{action}, arguments in the body), and it is the same convention POST /FCC/items and the four reporting endpoints follow.
A controller of its own and not an addition to FccAdminController. That one holds the levers pulled during normal operation and the two configuration tables an installation is tuned through; this one holds a migration that is run, watched, and switched off afterwards. They also differ in their access rule — and mixing two authorization models into one class is how the weaker one ends up applied to the stronger one’s endpoint.
|
All three require Three independent reasons, each sufficient on its own:
This product has no role model at all (No Authentication by Design), so scope A missing or non- |
POST /FCC/admin/legacy-import/start
Request (FccLegacyImportStartRequest), every field optional:
| Field | Type | Notes |
|---|---|---|
|
|
Which legacy tables to import; null or empty means both. They are imported one after the other by a single worker and each carries its own cursor, so starting one alone is a legitimate way to retry a kind that failed without disturbing the one that is fine. Duplicates in the list are collapsed. |
|
Boolean |
Measures the remaining work, records it as |
|
Integer |
Overrides |
The remaining work is counted first and always, dry run or not. It is the only number that makes the progress readable as a fraction, and taking it after the claim would leave a run that is already importing with no denominator until its first batch. Its cutoff is taken from now rather than from the row’s started_at, because the count happens before the claim decides whether this start is a fresh pass or a resumed one — so a resumed pass’s denominator is a few hours wider than the interval it will actually cover.
A pass never reads up to the present. It covers start_date up to startedAt - ft.fcc.legacy-import.window-settle - 24h, so the newest roughly 25 hours of measurements are picked up by a later pass. That buys a watermark that cannot skip a row — see Expect Roughly 25 Hours of Lag.
The claim is all-or-nothing. If the second kind cannot be claimed the first is released again: a start that answers 409 must not have left half an import running, or the operator retries and finds half of it is now "already running".
Response: 200 with the same body status returns (see below). The call returns as soon as the kinds are claimed; the pass itself runs on the fcc-legacy-import worker thread.
POST /FCC/admin/legacy-import/status
No request body. Response (FccLegacyImportStatusResponse): enabled, configuredZoneId, and kinds — one FccLegacyImportKindStatus per result kind, always both, created on demand if the installation has never imported.
| Field | Notes |
|---|---|
|
Whether |
|
The zone a new pass would read the naive legacy timestamps in. A pass already under way keeps the zone recorded on its own row ( |
|
|
|
One position, five fields plus the rank inside it. The traversal order is |
|
The different question of what is complete: every row whose |
|
The highest legacy id a completed pass has covered. A diagnostic high-water mark that bounds no read — completeness is |
|
Non-null means a batch was handed to ClickHouse and not acknowledged — the import either is inside that insert right now or died inside it. The next start re-checks exactly that id range before re-importing it, for the campaign |
|
A denominator for a progress bar and nothing else: the legacy flow keeps writing while the import runs, so nothing decides that the import is finished from it. |
|
|
|
Measurements that reached |
|
Rows that cannot be expressed as a cursor position at all — a null |
|
Data-quality tallies. Every one of those rows was imported anyway — see Legacy FCC Import for what each degradation means. |
|
What this run created in |
|
Whether a cooperative stop is pending. |
The counters describe the current or last run: a fresh pass zeroes them, a resumed one continues them.
POST /FCC/admin/legacy-import/stop
Request (FccLegacyImportStopRequest, optional body): kinds, null or empty meaning both.
The stop is cooperative and returns immediately. The flag is recorded, the running import notices it between two batches, and the row ends IDLE on a committed cursor that the next start resumes from exactly. state reads STOPPING in between, which exists so an operator can see the request was recorded rather than pressing stop again.
There is deliberately no forced variant. Being killed mid-batch is exactly what the pending id range and the recovery probe exist to survive — not a thing to offer a button for.
Stopping an import that is not running is not an error; the flag is simply cleared by the next start. Response: 200 with the status body.
Status Codes
| Status | When |
|---|---|
200 |
Answered. All three return the status body. |
400 ( |
|
403 ( |
|
409 ( |
|
503 ( |
|
Endpoints Implemented in M5
Everything M2 named is implemented and documented under Endpoints Implemented in M2; everything M3 named is implemented and documented under Endpoints Implemented in M3; everything M4 named is implemented and documented under Endpoints Implemented in M4. M5 closes the cross-repo contract’s remaining named surface with two endpoints, both behind FccTestConfigIoController.
PUT /FCC/item/export
Stream one campaign’s configuration out as an XML document — formatVersion, name, type, domain, reactivationDays, the config block, the matching settings block and the frozen fleet by serial, and nothing that happened to the campaign afterwards (no schedule, no state, no run history). See Configuration Import and Export for the full element vocabulary, what is deliberately not carried and why, and the reasoning behind every rule below — this section is the request/response contract only.
Request (FccTestExportRequest): {"id": 42}, DEV-1381 #12’s shape and Group Update’s own — POST /GroupUpdate/groups/export takes an IntIdRequest and writes one group. The ids list this endpoint used to take is gone, and with it the 500-id cap, the de-duplication and the all-or-nothing rule that list needed.
|
The document format still carries many, and that is not a leftover. |
Response: 200, Content-Type: application/xml, Content-Disposition: attachment; filename="fcc-<campaign name>-<yyyyMMdd-HHmmss>.xml" (UTC), body a StreamingResponseBody. No Content-Length — the size is unknown until the last device of the fleet has been read, which is the point of the endpoint: the fleet travels in pages of 1000 serials, so a campaign of twenty thousand devices costs constant memory. (Group Update buffers its whole document into a temp file and can therefore state a length; this one cannot and must not.)
The file is named after the campaign now that one file holds one: an operator exporting five campaigns gets five distinguishable files rather than five timestamps a second apart — which is why Group Update names its own export after the group too. The name is sanitised, not quoted: everything outside [A-Za-z0-9._-] collapses to a single dash, the result is cut to 80 characters, and a name that leaves nothing at all — one written entirely in a non-Latin script — falls back to the id. A campaign name is 255 characters of whatever the operator typed, and three of those characters are a problem in a different layer: a path separator in a download name, a quote that would end the filename="…" parameter early, and a CR or LF that would split the header itself.
| Status | When |
|---|---|
200 |
The id existed, was visible to the caller and named no legacy anchor. The document then streams; see below for what a failure during the stream looks like. |
400 ( |
The id names a legacy anchor ( |
404 ( |
The id does not exist, or exists outside the caller’s visible domains — never 403, for the reason every other endpoint of this service gives (LIST vs. ALL Filtering). |
Every check above — existence, visibility, the legacy-anchor rule — runs inside FccConfigExportService.prepare, in one @Transactional(readOnly = true) block, before the StreamingResponseBody is even constructed. Once the status line is on the wire nothing can become an error any more: a failure mid-stream (a database hiccup, an operator closing the tab) can only close the connection, and the client sees a truncated file — the identical rule, for the identical reason, as POST /FCC/reports/export.
PUT /FCC/item/import
Read a configuration document into a campaign form. It creates nothing. DEV-1381 #13 asks for "the same as PUT /GroupUpdate/groups/import`", and that endpoint parses the file and returns `GroupUpdateGroupDetailsRequest — the form payload, un-saved. Its screen shows the form and the operator presses Save, which is a different endpoint. This one is the same: a file in, the details object out.
Request: multipart/form-data, part file only — there is deliberately no raw application/xml variant of this path; see Transport: Import for why. A missing file part is 400/9400; a part offered with an unsupported content type is 415/9415; an upload past spring.servlet.multipart.max-file-size (FCC_SERIAL_FILE_MAX_SIZE, default 10 MB) is 413/9413 — the shared vocabulary of the multipart status codes. The file name must end in .xml, checked by name so that the message can say so, exactly as PUT /FCC/devices/import does.
Response (FccTestDetailsResponse): the very body POST /FCC/item/details answers, which is the body PUT /FCC/item/details accepts — so the import, the form and the save speak one object.
-
idisnull, because nothing exists yet; that null is precisely what the save reads as "create". -
stateisNOT_ACTIVE— not a state anything is in, but the state the save will create. An imported campaign never arrives running, scheduled or with a history, whatever installation the file came from. -
activation.typeisImmediately. The document deliberately carries no schedule (What the Format Does Not Carry), and answering the parsednullwould leave the form’s own radio unset on a screen about to be saved. -
configs[0].sourceTypeisIndividualand the rest of the choice is empty. The document carries a fleet and not the selection that produced it, so no manufacturer, model or condition is invented from the devices that happened to resolve;Individualis what makes the save readselectedDevices. -
selectedDevicesare cpe ids, resolved from the document’s serials through the save path’s ownresolveForSave, scoped to the caller’s domains.
| Status | When |
|---|---|
200 |
The document described one campaign and every serial in it resolved. The answer is the form. |
400 ( |
Everything the document can get wrong, at any point in the parse — a file name not ending in |
413 ( |
The multipart upload itself, before the document is read at all — see above. |
500 |
|
|
What this endpoint used to be, and what went with it. It created a campaign per
The validations that moved to the save are the campaign’s, not the document’s: the URL and range rules, the connection ceilings of the resolved fleet, the three-level settings resolution and the domain. They run when the operator saves what they were shown. |