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

X-FT-User-Id

iotw_user.id from the JWT session.

Contractually yes, but its absence does not fail the request — audit columns fall back to the literal system.

X-FT-Domain-Id

iotw_user.domain_id.

Only when X-FT-Domain-Scope: LIST. May be absent with ALL (a super-domain’s domainId is a sentinel: null, 0, or -1).

X-FT-Domain-Scope

ALL for a super-domain caller, LIST otherwise.

Yes. Absent or unparseable → HTTP 403, never a wildcard. LIST with an empty X-FT-Domain-Ids also → 403: a caller who can see no domain can see no test.

X-FT-Domain-Ids

CSV of visible domain ids (no null`s), when scope is `LIST. Above 200 ids, split across X-FT-Domain-Ids-1 …​ -N, 200 per header.

When scope is LIST.

X-FT-Client-Type

mc / sc (ClientType.getName()).

Contractually yes; carried for logging only in v1.

The chunk reader (FccIdentityFilter.readDomainIds) concatenates X-FT-Domain-Ids and then X-FT-Domain-Ids-1, -2, …​ until a header is absent, de-duplicating into one ordered list. A blank or non-numeric entry is dropped, never causes a 400: a malformed id can only shrink the caller’s visible-domain set, never widen it.

Two different FT codes answer HTTP 403, and a client switching on the numeric code must handle both.

Code Raised by Means

9401 IDENTITY_MISSING

FccIdentityFilter, before any controller runs

The headers were not satisfactory: X-FT-Domain-Scope absent or unparseable, or LIST with an empty id list. description names the header. This is almost always a ui-backend proxy misconfiguration rather than a client bug.

9403 DOMAIN_DENIED

FccAccessDeniedException, inside a controller

The headers were fine and the request was refused: a domainId outside the caller’s visible domains on PUT /FCC/item/details or PUT /FCC/admin/domain-setting, or a model-capability write without scope ALL.

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

9400 VALIDATION_FAILED

400

FccValidationException, bean validation on a body, or an unreadable body. Also a multipart request with no file part.

9401 IDENTITY_MISSING

403

FccIdentityFilter, before any controller. See [two-403-codes].

9403 DOMAIN_DENIED

403

FccAccessDeniedException.

9404 NOT_FOUND

404

FccNotFoundException — including a resource that exists in a domain the caller cannot see.

9409 CONFLICT

409

FccConflictException — the frozen device set, and the state-machine denials that are not folded into a per-id error.

9413 PAYLOAD_TOO_LARGE

413

M3. MaxUploadSizeExceededException on the multipart endpoints of /FCC/devices/; the message names the configured maximum when one is knowable**, and carries no figure at all when it is not — see the multipart status codes.

9415 UNSUPPORTED_MEDIA_TYPE

415

M3. HttpMediaTypeNotSupportedException — a request or a multipart part offered with a content type nothing accepts.

9500 INTERNAL_ERROR

500

The catch-all. Never a stack trace; always the generic text.

9503 SERVICE_DEGRADED

503

FccDegradedException, raised by FccResultStorageGate.require(…​) — the ClickHouse result store cannot take rows, either because it is unreachable or because it is reachable but its migrations are behind (SCHEMA_BEHIND); description says which. See the three ClickHouse states.

9413 and 9415 are how the catalog grows. A failure this service has to report and that no constant covers gets one whose last three digits are the HTTP status it is answered with. Both were previously reported as 9500 by the catch-all; a code that did not follow the rule would be the one nobody can map on the ui-backend side without reading the enum.

They exist at all because DefaultHandlerExceptionResolver is the last resolver in the chain while an @ExceptionHandler(Exception.class) in an advice runs before it — so the catch-all masks Spring’s own perfectly good answer. The three dedicated handlers at the bottom of FccExceptionHandler hand back the status Spring would have used, with the usual body and a description that says what to send instead. They are not redundant; do not delete them.

LIST vs. ALL Filtering

  • Scope LIST: every read against this service’s own tables is narrowed with fcc_test.domain_id IN (:ids); every read against ftacs is narrowed with cpe.location_id IN (:ids) (see FtacsDeviceResolveDao, which is the single place that column-name mapping is written down). If the caller is visible in more than 1000 domains (Oracle’s IN-list limit), the predicate is applied in memory instead against a single already-fetched row or a bounded fleet — see FccDbConstants.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, never 403: 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

unrestricted() — domainIds = null

No domain predicate at all. Only a scope-ALL caller that named no domain gets it.

of(ids)

…​ IN (:domainIds), or the in-memory narrowing above 1000 ids.

nothing() — domainIds = [], selectsNothing = true

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

LIST

Taken from X-FT-Domain-Id. Must be positive and must be one of the caller’s own visible domains, or FccValidationException / FccAccessDeniedException is thrown. The request body’s domainId field is ignored on create and never re-read on update (re-reading it on update with scope LIST would let a caller silently move a test out of a sub-domain into their own).

ALL

Taken from the body’s domainId field, which is mandatory in this case: a super-domain UI has no single "own" domain, and the sentinels it sees in the header (null, 0, -1) are not real domains, so it must name one explicitly. domainId ⇐ 0 is rejected.

Endpoints Implemented in M1

Four endpoints shipped in M1: PUT /FCC/item/details, POST /FCC/item/details, POST /FCC/items (FccTestController), and POST /FCC/activate (FccActivationController, backed by FccActivationService / FccActivationHandler and the com.friendly.fcc.orchestration outbox). The 503-on-ClickHouse-unavailable rule described under POST /FCC/activate is wired and live: FccResultStorageGate.require(…​) checks FccDegradedState.clickhouseStatus() before starting anything — see the three ClickHouse states.

M2 extended all four — the LATENCY test type on PUT /FCC/item/details and POST /FCC/item/details, mode=SCHEDULED on POST /FCC/activate — and added five more, documented under Endpoints Implemented in M2.

M3 extended PUT /FCC/item/details again, with a selection as an alternative to an explicit device list and four new validators, and added eight more paths (twelve operations across them); see Endpoints Implemented in M3. DEV-1381 #10 then reshaped the whole body into configs[] — see PUT /FCC/item/details: Where the Fleet Comes From.

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. activation.totalTestDuration is in hours; activation.singleTestDuration and activation.batchInterval are in minutes. The previous names (totalDurationH, singleDurationMin, batchIntervalMin) said so and the new ones, which come from DEV-1381, do not — a caller who sends 60 meaning minutes gets a sixty-hour campaign that is accepted, dispatched, and noticed days later.

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 (url_dl, total_duration_h), which is a mapping JPA declares once on the entity: a renamed JSON field does not drag a schema migration behind it.

Request (FccTestUpsertRequest):

Field Type Notes

id

Integer

null to create.

domainId

Integer

Read only when scope is ALL — see How fcc_test.domain_id Is Chosen on Create.

name

String, required, max 255

activation

FccTestActivationRequest

type (Immediately | Scheduled, absent means Immediately) and date — the campaign’s timing, stored on fcc_test.scheduled_at and committed by POST /FCC/activate. date is required with Scheduled, refused without it, must be in the future, and may only be set while the campaign is NOT_ACTIVE — rebooking a SCHEDULED or RUNNING campaign would move a commitment the scheduler is holding from under it, so it is a 409 telling the operator to stop it first. Saving never starts anything. Then: totalTestDuration (HOURS), singleTestDuration (MINUTES), batchInterval (MINUTES), failedTestRetries, wanUsageRetries, onlyOnlineDevices, requestDeviceConnect, and the four the ticket does not name — wanSampleGapSec, wanRetryIntervalSec, wanStepTimeoutSec, diagTimeoutSec — which fall back to fcc_domain_setting and the FT Configs runtime defaults, resolved once at save time (The fcc_activation Settings Snapshot). totalTestDuration and singleTestDuration are both required and positive, and their quotient must fit at least one iteration — see The Window Must Fit at Least One Iteration.

reactivation

FccReactivationRequest

The repetition rule, which replaced the bare reactivationDays integer. type (Daily, required when the block is present), repeatEvery (days between runs, absent means 1), startsOn (the anchor the slots are counted from), ends (After, or absent) and reactivationCount (0..9). Absent is a one-shot campaign, and an update that omits it clears a rule the campaign had. See PUT /FCC/item/details: Reactivation Is a Rule, Not a Count.

configs

List<FccTestConfigRequest>, required, exactly one

What to measure and on which devices: id, manufacturer, model, sourceType, conditionId, selectedDevices, unselectedDevices, settings. SEVERAL ELEMENTS ARE ACCEPTED, each with its own settings and its own fleet, and each becomes its own ACS activation on every run — see PUT /FCC/item/details: Several Configurations, and Whose Limit Bounds Them. There is no diagnosticsType any more: settings[0].type is "Speed test" or "Latency" and is the one place the type is stated, deciding which half of the settings object is read; the other kind’s fields present is a 400 naming them (PUT /FCC/item/details: The Type Is Stated Once, and diagnosticsType Is Why). sourceType decides the fleet — see PUT /FCC/item/details: Where the Fleet Comes From.

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

id

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.

type

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 ACTIVATE command is built from, so a configuration without it cannot be dispatched at all. configs[].diagnosticsType carried the same value beside it and was removed — see PUT /FCC/item/details: The Type Is Stated Once, and diagnosticsType Is Why.

speedTierMode

"default" or "manual" — where the tier came from, a preset of the model or a figure the operator typed. See speedTierMode Is Provenance, Not a Rule.

The kind-specific halves:

Kind Fields

"Speed test"

downloadUrl, uploadUrl, wanDownloadThreshold, wanUploadThreshold, speedTierDownload, speedTierUpload, downloadConnections, uploadConnections, fileSizeUpload, testMode (FILE_BASED | TIME_BASED — both run since contract 1.6), timeBasedDurationSec, timeBasedIntervalSec (seconds, read only with TIME_BASED and refused otherwise — see testMode: FILE_BASED and TIME_BASED, and What the Second One Costs), speedSuccessPercent, hubbId.

"Latency"

testHost, wanUsageThreshold, speedTier, repetitions, timeoutMs, dataBlockSize, latencySuccessMs, hubbId. testHost / repetitions / timeoutMs / dataBlockSize are the TR-181 IPPing parameters the ACS drives; latencySuccessMs is not one of them — it is the pass/fail bound this service applies. There is no upload counterpart to wanUsageThreshold / speedTier: a ping is symmetric. See fcc_test_setting_latency.

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

configs empty, or carrying two configurations

400

FccTestValidator.validateConfigs: "configs carries 11 configurations; at most 10 are accepted (ft.fcc.max-configs-per-test)", with the reason. The ceiling is this save’s and not the ACS’s — see PUT /FCC/item/details: Several Configurations, and Whose Limit Bounds Them.

configs[].settings[0].type absent

400

"configs[0].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". Never guessed from whichever settings half happens to be filled in.

configs[].settings absent, empty, or carrying two objects

400

FccTestValidator.settingsOf. One object already carries both directions of a speed test and both ends of a latency one, and the ACS activation carries one set — a second would be stored and never run.

settings[0] carrying the other kind’s fields

400

FccTestValidator.validateSettingsMatchType: "settings[0].type=\"Speed test\" does not accept the settings of \"Latency\"; the same object carries testHost, repetitions". The message names the offending fields — "settings do not match the type" alone leaves the operator hunting through a form. See The Settings Block Must Match the Type.

configs[].sourceType absent

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.

configs[].sourceType = All without both manufacturer and model

400

Without them it selects every device the caller can see, which no screen offers and a mis-click would otherwise save.

configs[].sourceType = Condition without a conditionId

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.

configs[].sourceType = Condition naming a condition this service does not translate in full

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 OR or nested, is refused rather than approximated.

configs[].sourceType = Condition on an installation with no iotw access

400

ft.fcc.iotw.enabled=false. The message says so and names the two grants, and the other three source types are unaffected.

latency.testHost missing or blank

400

FccTestValidator.validateLatencySetting. The one value with no default anywhere: the ACS writes it into TR-181 IPPing.Host and cannot ping nothing, so an absent host is a command the ACS refuses as INVALID_CONFIG.

latency.repetitions, latency.timeoutMs or latency.dataBlockSize present and ⇐ 0

400

"latency.repetitions=0 must be positive" and the two siblings. Checked for sign only, and only when given — the three fall back to FccRuntimeDefaults (4 repetitions, 1000 ms, 64 bytes) at save time when absent. Refused rather than clamped: NumberOfRepetitions=0 measures nothing at all and Timeout=0 is a ping that has already timed out, and both come back as a run with no measurements rather than as an error anybody can see.

activation.totalTestDuration or activation.singleTestDuration present and ⇐ 0, or — when both are present — their quotient below one iteration

400

FccTestValidator.validateSchedule. Presence is not required at save time; it is required at activation time — see The Window Must Fit at Least One Iteration.

reactivation.reactivationCount outside 0..9

400

FccTestValidator.validateReactivation: "reactivation.reactivationCount=12 is out of range; allowed: 0..9". The same validator refuses a rule with no type, a repeatEvery outside 1..365, and ends=After with no count. See PUT /FCC/item/details: Reactivation Is a Rule, Not a Count.

speed.testMode = TIME_BASED without speed.timeBasedDurationSec

400

FccTestValidator.validateTimeBasedParameters: the duration is the TR-143 TimeBasedTestDuration the ACS writes to the device, and without it the device transfers for however long its own default is — a measurement whose window nobody chose. The blanket "testMode=TIME_BASED is not supported in v1" refusal that used to stand here is gone; see testMode: FILE_BASED and TIME_BASED, and What the Second One Costs.

speed.timeBasedDurationSec or timeBasedIntervalSec on a FILE_BASED block

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 validateSettingsMatchType applies to a latency host on a speed campaign.

speed.timeBasedIntervalSec greater than speed.timeBasedDurationSec

400

The interval is the STEP of the TR-143 IncrementalResult table, so a step longer than the window measured yields a table with no rows at all — a result that looks complete and carries no breakdown.

speed.timeBasedDurationSec longer than one iteration

400

FccTestValidator.validateTimeBasedWithinIteration: activation.singleTestDuration is the iteration length in MINUTES and the duration is in SECONDS, and the refusal names both numbers in both units. An iteration has to accommodate a download leg, an upload leg AND the WAN samples between them, so a longer duration is a test that can never finish — the device is cut off mid-measurement and the iteration reports nothing. The bound is the WHOLE iteration and not half of it: requiring twice the duration to fit would refuse a download-only campaign that works perfectly well, and how the legs are sequenced is the ACS’s business.

speed.testMode = TIME_BASED over a fleet that is not opted in

400

FccTestValidator.validateTimeBasedSupport: every model of the configuration needs an fcc_model_capability row with supports_time_based, and a model with no row at all counts as NOT supporting it — recording the capability is how a model is opted in, and defaulting to true would dispatch to devices that cannot run the test. The refusal names the model ids and points at PUT /FCC/admin/model-capability. A device carrying no product_class is refused by cpe id instead, because it has no model whose row could opt it in.

speed.downloadConnections or speed.uploadConnections outside 1..4

400

M3. FccTestValidator.requireConnectionsInRangeWhenPresent: "speed.downloadConnections=7 is out of range; allowed: 1..4". 4 is a product cap and not a protocol limit — TR-143 states none — because more parallel sessions measure the test server’s capacity rather than the subscriber’s line. The same static method judges `PUT /FCC/admin/model-capability’s ceilings, so a ceiling that a campaign could never satisfy cannot be stored as one.

speed.downloadConnections / uploadConnections above the fleet’s capability ceiling

400

M3. FccTestValidator.validateConnectionCeilings, against fcc_model_capability and the models of the resolved fleet. Refused, never clamped — see The Capability Ceiling Is Refused, Not Clamped.

speed.downloadUrl, speed.uploadUrl present and not an absolute http / https / ftp URL with a host

400

M3. FccTestValidator.requireDiagnosticUrlWhenPresent. Three distinct messages — not a URI at all, no scheme, an unsupported scheme, or no host. FTP is deliberately allowed: TR-143 names it explicitly alongside HTTP for both diagnostics and FT installations do run ftp test servers. A blank value is left alone here; absence is validateRunnable's business, which reports every missing field of an activation in one 400.

speed.speedTierDownload/speedTierUpload/wanDownloadThreshold/wanUploadThreshold, or latency.speedTier/wanUsageThreshold, present and ⇐ 0

400

M3. FccTestValidator.requirePositiveWhenPresent: "speed.speedTierDownload=0 must be positive". Sign only — a tier is never matched against the preset list POST /FCC/manufacturerAndModelByTestType serves, see Tiers Are Served, Not Hard-Coded in the UI.

configs[].selectedDevices naming an id the caller cannot see, or that does not exist

400

FccTestValidator.resolveDevices: "unknown or out-of-scope cpe ids (2): 41, 42". The two cases are indistinguishable here, and that is the point — neither comes back from ftacs.cpe. unselectedDevices is the opposite rule: an exclusion that matches nothing is not an error, because the operator wanted that device out and it is out.

sourceType = All resolving a serial that matches no device visible to the caller

400

FccDeviceResolveService.resolveForSave, naming the count and the first 20 offenders in the caller’s own spelling. The wizard path reports such a serial and carries on; the save refuses — see notFoundSerials and duplicateSerials Are Two Different Defects.

A configs[] selection resolving to more than ft.fcc.max-devices-per-test devices

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.

devices.size() > ft.fcc.max-devices-per-test (default 20000)

400

FccTestValidator.resolveDevices. The pilot limit noted in the cross-repo contract (500 devices) is an operational recommendation for v1, not a hard limit enforced in this validator; the hard limit enforced in code is ft.fcc.max-devices-per-test.

Any submitted cpeId unknown, or outside the caller’s visible domains

400

Reported as a list of the offending ids (capped at 20 in the message), resolved via FtacsDeviceResolveDao against ftacs.cpe.

Any device newly added to the test whose resolved serial is missing or blank

400

Reported as a list of the offending cpeId`s in the message. `FccDeviceDto.serial is mandatory in wire contract 1.3. The check covers the whole list on create and, on update, only the submitted `cpeId`s that are not already stored — a device already in the fleet never blocks a save. See A Device Without a Serial Is Refused — Twice.

Editing the device fleet on a test with activationCount > 0

409

FccTestValidator.validateDeviceSetEditable — the message points the caller at PUT /FCC/item/export followed by PUT /FCC/item/import, which answers the campaign as a new unsaved one whose fleet can be changed before saving. (POST /FCC/item/clone did this in one step and was removed — it was never part of DEV-1381, and the file can be edited in between and carried to another installation.) The comparison is on the resulting set of cpeId`s and is made after resolution, so a `configs[] selection whose manufacturer, model or conditionId was reworded but which resolves to exactly the stored devices is a legal no-op edit, while a list the operator believed was the same one is refused if it resolves differently. What a compliance report has to describe is the devices that were measured, and neither the wording of a condition nor the order of a list changes those.

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

fcc_domain_setting.latency_host could never satisfy validateLatencySetting, because the host was judged before the domain value was read. The one request the column exists for — a campaign that omits the host on a domain that publishes one — was refused with "latency.testHost is required", and the whole per-domain host feature was inert.

A domain default that breaks a rule

A malformed per-domain downloadUrl / uploadUrl reached the database unchecked, because requireDiagnosticUrlWhenPresent only ever saw the request’s own URL. The campaign then failed at the ACS, naming a URL the operator never typed.

One resolution, then one validation of its result. validateConnectionCeilings is judged on the resolved record too, for the same reason.

validateSettingsMatchType stays first, and is the exception.

It is about which block the body carries, which resolution cannot change and must not be allowed to hide. A LATENCY test carrying speed settings has to be refused as the contradiction it is (The Settings Block Must Match the Type), not quietly filled in from a domain default and saved.

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. saveSettings receives 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).

validateSchedule only refuses a value that is present and non-positive, and a pair of numbers that can never work together. It does not require either field, and requiring them would break a supported flow: saving a campaign with no config block at all is legal — FccTestService substitutes an empty one and fills in the runtime defaults — because drafting a campaign and filling in its schedule later is how operators work. Worse, a stored campaign whose singleDurationMin is null could otherwise never be saved again, not even for a rename.

validateRunnable is the gate: it refuses to activate a campaign missing either activation.totalTestDuration or activation.singleTestDuration, and reports them in the same one-message list as everything else. singleDurationMin has to be caught there specifically, because validateSchedule only judges the two numbers together — an absent iteration length would otherwise send an ACTIVATE carrying a null the ACS divides the window by, producing a run that is accepted, computes no iterations and closes having measured nothing.

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

PUT /FCC/item/details, when a device is added to the fleet

A device that has no serial in ftacs.cpe at save time — the whole fleet on create, and on update only the submitted `cpeId`s that are not already stored on the test. The response message lists the offending `cpeId`s. A device that is already part of the stored fleet is never re-checked here, so an edit that does not touch the fleet cannot fail on it.

POST /FCC/activate, when the test is activated

The same condition on a fleet that was stored before the rule existed, or whose ftacs.cpe rows have changed since. It is reported together with the other missing prerequisites in one message (see POST /FCC/activate), not as a separate round trip. As with every per-test validation failure on that endpoint, the 400-shaped reason is folded into that test’s error and the request itself still answers 200.

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

state

FccTestStateEnum

This service’s own lifecycle vocabulary, unchanged — the ticket’s example shows SCHEDULED, which is one of its constants.

activation

FccTestActivationRequest

The timing knobs from fcc_test_config plus the campaign’s own booking: a stored scheduled_at is reported as type: "Scheduled" with that date, its absence as "Immediately". A config row that does not exist yet still answers with the booking — a draft saved with a date and nothing else is a legal campaign.

reactivation

FccReactivationRequest

null for a one-shot campaign, which is exactly the body the save accepts for one. Otherwise Daily, repeatEvery and startsOn from their columns, and ends: "After" with the count. See ends Is Rendered, Not Echoed.

configs

List<FccTestConfigItem>

One element per stored configuration, in the order they were saved — which is the order they were PUT in. id, manufacturer, model, sourceType, conditionId, conditionName, devicesCount, selectedDevices, settings. No diagnosticsType: the type is inside settings[0].

FccTestConfigItem is not FccTestConfigRequest, and the three differences are the point:

Field Why it differs

conditionName (read only)

The saved condition’s name, read from the iotw schema (sourceType=Condition: The Subset of the Portal’s Conditions This Service Translates). It can be null without that being an error: a condition deleted in the portal since the campaign was saved, one this caller may not see, an installation with no access to that schema and one with the feature switched off all answer null, because a label on a screen may never fail a read of a campaign. Render the conditionId you already have when it is absent.

devicesCount (read only)

How many devices the frozen fleet holds. It cannot disagree with selectedDevices — both come from one read of fcc_test_device — and it is here so a screen that shows only the figure need not count an array of twenty thousand.

unselectedDevices (write only)

Has nothing to report on the read: the exclusions were applied when the fleet was resolved, so selectedDevices is what is left. A second list of what is not in it would be a figure with no stored truth behind it.

The choice is read, never re-derived from the fleet.

manufacturer, model, sourceType and conditionId come from fcc_test_config — the four columns DEV-1381 #10 added for exactly this read. A campaign with no recorded choice — saved before those columns existed, or imported from a configuration document, which carries a fleet and not the choice behind it — answers null for them.

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. selectedDevices is the frozen fleet either way (fcc_test_device), so these are the devices the campaign will run against even if the condition that selected them would resolve to something else now.

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.

activations[] — the run history, fifty summaries of fcc_activation — is not lost: POST /FCC/reports/list answers one row per run filtered by testId, with the figures a history screen shows. It became a query the history screen makes for itself rather than fifty rows every details read pays for.

devicesLocked is a real loss. It was activationCount > 0, and it told the UI that the fleet is frozen and that it must offer a new campaign instead of an edit. No field of this answer implies it: state comes closest and is wrong in one case, because a SCHEDULED campaign has never run and its fleet is still editable. Without the flag a UI finds out by being refused with a 409 on save. It is one field to restore if the screen needs it; the rule behind it is FccTestValidator.validateDeviceSetEditable.

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

manufacturer

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".

model

String

The same, for product_class.model. Given together with manufacturer it narrows: a device has to be of a model that is both.

state

FccTestStateEnum, or ALL

Exact match, except for ALL, which means no filter — the same answer as omitting the field or sending it blank. It exists because a dropdown needs an "all states" entry to send, and it is deliberately not a constant of FccTestStateEnum: that enum is the persisted fcc_test.state both state machines switch on exhaustively, so a constant there would be a state a campaign could be saved in and a branch neither machine could ever reach. FccTestStateFilterDeserializer reads it on this one field and translates it to the absent filter the specification already understood, which keeps ALL and omission one behaviour rather than two code paths. Read case-insensitively. Every response still answers one of the nine real states and never ALL. Any other value is a 400 naming the value and the allowed set — where an unparseable enum otherwise answers the unhelpful "request body is not readable".

pageNumbers

List<Integer>, 1-based

Absent or empty means [1]; a number < 1 is read as 1; repeats collapse. Several numbers are answered in one response, their rows concatenated in the order asked, and pageDetails.currentPage echoes the list back — the shape FTPageDetails.currentPage has always had. One response carries at most 500 items in total (pageNumbers.size() * pageSize); beyond that is a 400. The page size is clamped and the page count is refused, because a clamped size still answers the question that was asked, while a dropped page silently answers a different one.

pageSize

Integer

Defaults to 25, capped at 500.

sorts

List<FccSort>

Each {field, direction}. field must be one of name, state, type, created, updated, scheduledAt (FccTestSortFieldEnum) or the request fails 400 listing the allowed set — an unrecognised field is never handed to a live sort expression. direction is ASC or DESC (case-insensitive), defaulting to ASC; anything else is 400. No sorts at all defaults to created desc.

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

id, name, state

As stored.

domain

The domain’s name, read from ftacs.isp — see Where the domain name, the creator and the formatted dates come from. Super domain for a campaign the operator owns, spelled exactly as CommonUtils.SUPER_DOMAIN_NAME spells it.

creator

Who saved the campaign, as the name ui-backend sent at the time. A campaign saved before X-FT-User-Name existed carries the user id; one written by a scheduler carries system. Rendered as stored — nothing is resolved or guessed on read.

created / updated / activated

Formatted in the caller’s zone and patterns (Where the domain name, the creator and the formatted dates come from).

createdIso / updatedIso / activatedIso

The same instants, unformatted. Sort and filter on these.

activated / activatedIso

When the latest run started; while a campaign waits on a schedule, when it is due. Empty for one that has never run.

activationMethod

Manual or Scheduled, null for a campaign that has never been activated. The constants are capitalised like this because they are ui-backend’s own `ActivationMethod values, which that screen already renders.

application

MP or SP, from the caller’s X-FT-Client-Type. It describes the portal asking the question and not the campaign; Group Update’s list does exactly the same, and matching it is the point.

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:

  • domain is read live from ftacs.isp, one batched query per page. That table is in the ACS schema, not in iotw: DomainEntity of api-orm maps to isp and carries no @DataSourceSchema, so ui-backend binds 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.name is sent verbatim, dotted path included, because that is the string ui-backend shows.

    isp.id and isp.name are 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.
  • creator is stored, not resolved. iotw_user lives in a schema this service has no account on, so ui-backend sends X-FT-User-Name and JPA auditing writes it into the creator column when the campaign is saved. A rename does not rewrite history, which is what an audit column is for; the user id remains in fcc_audit.

  • created / updated / activated are formatted with X-FT-Zone-Id, X-FT-Date-Format and X-FT-Time-Format — the last three arguments of the DateTimeUtils.formatAcs call ui-backend makes 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/yyyy and HH: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 — unlike X-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_id was resolved from ftacs.cpe when 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 IN list 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 name beside 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 scheduled_at

Creates the run and writes its ACTIVATE command in one transaction — what mode=IMMEDIATELY did.

scheduled_at in the future

Moves the campaign to SCHEDULED and creates no run — what mode=SCHEDULED did. The activation scheduler claims it when the instant arrives (state = 'SCHEDULED' AND scheduled_at ⇐ now), see A Booked Campaign Creates No Activation Row.

scheduled_at in the past

Refused for that id, 409. The operator booked 10:00 and pressed Activate at 10:05; starting the fleet now is not what they asked for, and for a speed test the difference between a booked hour and now is the difference between a quiet line and a peak-hour one. The message says to edit the date or clear it.

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 ACTIVATE commands are queued. The boolean cannot say that, so the error does: how many were accepted, and every id that failed with its reason.

The status is the most severe outcome of the batch — 503 degraded, then 409 conflict, then 404/403, then 400 — see FccBulkOutcome, which owns that ranking for every bulk endpoint of this service.

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

true: every id was activated or booked.

400

The body failed bean validation (ids empty), or a campaign was refused with a 400-shaped reason and nothing more severe happened in the batch — the description names how many were activated and which ids failed.

503

The result store cannot take rows — either it is unreachable, or reachable with its ClickHouse schema behind (FccDegradedException / FT code 9503, checked once for the whole request by FccResultStorageGate.require(…​) before any test is processed; the two causes are told apart by details.reason on /actuator/health, see the three ClickHouse states). Why 503 and not a per-test refusal: results are the product of a compliance campaign; accepting an activation whose measurements cannot ultimately be written would not fail loudly — the ACS would run the diagnostics, the devices would be disturbed, and the campaign would simply end up with a hole in a regulatory report. This is exactly why ClickHouse unavailability is modelled as service-wide DEGRADED (Health) rather than as DOWN: CRUD on ftfcc stays available throughout, only new result-producing work is refused.

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 (FccTestValidator.validateActivatable)

Since M2 this is no longer a local EnumSet — it is the ACTIVATE_IMMEDIATELY column of the campaign transition table, and the validator only translates a Denied into the 409 an HTTP caller expects (folded into error — see above). RUNNING is the only state that refuses it, plus the unreachable ARCHIVED: a run is already in flight and a second ACTIVATE for the same campaign would make two runs write results under it. NOT_ACTIVE, STOPPED, PAUSED, COMPLETED, ERROR, SCHEDULED and SCHEDULED_FOR_REACTIVATION all accept it, because every run gets its own fcc_activation row and its own settings snapshot.

Activating a campaign in SCHEDULED_FOR_REACTIVATION also stops the run it already owns

The campaign is between daily runs and already holds its next one as a PLANNED row; the operator is asking for a different run right now, so the old one is superseded. FccActivationHandler.stopSupersededRun closes it with stop_reason = USER_STOP — locally and with no command when it was still PLANNED, or with a real STOP when it had reached DISPATCHING/RUNNING. It happens before the new run’s command_seq is drawn, so the STOP takes the lower sequence. See the superseded-run rule.

Schedulable states (FccTestValidator.validateSchedulable)

The ACTIVATE_SCHEDULED column of the same table, which is not the same rule: SCHEDULED_FOR_REACTIVATION accepts an immediate run and refuses being re-scheduled. SCHEDULED accepts it — moving the instant of a run that has not started yet is an edit, not a second run, and refusing it would force the operator to stop the campaign and schedule it again. See A Booked Campaign Creates No Activation Row.

Pre-flight completeness (FccTestValidator.validateRunnable)

Reported together as one message listing every missing item: at least one device (fcc_test_device non-empty), every device in the fleet carrying a non-blank serial (A Device Without a Serial Is Refused — Twice), activation.totalTestDuration > 0 and activation.singleTestDuration > 0, and the settings the type calls for. The settings half branches on the type, the fleet half does not: a SPEED test needs a speed row with both speed.downloadUrl and speed.uploadUrl non-blank and speed.speedTierDownload non-null, a LATENCY test needs a latency row with a non-blank latency.testHost. Judging a latency test by the speed rules would refuse every one of them; judging it by no rules at all would dispatch a command with an empty config.ping. speedTierUpload, by contrast, is not checked for either type — only speedTierDownload is required for a speed test to be runnable in v1.

The two machine-driven entry points — a due SCHEDULED campaign and a due PLANNED reactivation — do not re-run validateRunnable. They check only that the test still has the config row and the settings row its type names (FccActivationHandler.hasSnapshotSettings) and otherwise skip-and-log rather than throw: they run on a scheduler tick, where a configuration that has since become unrunnable comes back from the ACS as REJECTED(INVALID_CONFIG) and closes the run through the normal ack path, visible in the run history, whereas throwing would roll the tick back and re-claim the same row forever. See The Scheduler / Transaction Split.

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, /item/pause and DELETE /FCC/items do not consult the result store. POST /FCC/activate refuses to start anything while ClickHouse is unreachable, because a run whose measurements cannot be stored is a hole in a regulatory report. Stopping has the opposite sense: refusing it would leave the fleet measuring into a store that is already down, which is strictly worse than letting the operator stop it.

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 NOT_ACTIVE. This follows Group Update, which has no "stopped" status at all: its stop returns the group to Created — the UI’s "Not active" — because that is what the group is afterwards. The same was always true here and only the label said otherwise: a stop and a pause run the identical code, neither can resume a run, and a later activate starts a brand-new one. So a campaign that was stopped sits in exactly the condition one that never ran sits in, and a campaign list filtered on NOT_ACTIVE now finds it.

STOPPED is not gone, and it now means one thing: the run was cut short and nobody asked for it. It is written only by RUNNING + ACTIVATION_STOPPED, which the activation-event consumer, the device-state drain and the stop-grace watchdog all skip for a user-initiated stop. The campaign list therefore gains a distinction it did not have — NOT_ACTIVE is "the operator stopped it", STOPPED is "the ACS or an outage stopped it" — where before both read STOPPED.

The constant stays in the state filter of POST /FCC/items, so a UI that still sends STOPPED gets an empty page rather than a 400. Existing rows are migrated: a campaign in STOPPED whose newest run was stopped with USER_STOP, or which has no run at all, becomes NOT_ACTIVE — changelog_migrate_fcc_test_stopped_to_not_active.xml.

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 — 503, then 409, then 404/403, then 400 (FccBulkOutcome).

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 PLANNED and had never been dispatched, so there was nothing to tell the ACS about — see commandPublished: false Is Not a Failure. The per-id envelope used to carry that as commandPublished=false; with a single boolean it is in the log line instead.

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:

  1. the campaign transition USER_STOP — RUNNING/SCHEDULED_FOR_REACTIVATION → NOT_ACTIVE, and SCHEDULED/PAUSED → NOT_ACTIVE with scheduled_at cleared;

  2. the run transition STOP_REQUESTED → STOPPING with stop_reason = USER_STOP and stopped_at set — for a campaign that owns a live run, which a SCHEDULED or PAUSED one does not (see A Paused Campaign Can Be Stopped);

  3. fcc_test.command_seq + 1 and a STOP row in fcc_acs_outbox with a fresh idempotency key;

  4. an fcc_audit row, 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

true: every campaign in the batch was stopped, paused or deleted as asked.

400

The request body fails bean validation (ids empty or containing a null), or a campaign was refused with a 400-shaped reason and nothing more severe happened in the batch.

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 SCHEDULED owns only a pending instant; clearing it is the whole stop.

  • The run was still PLANNED. No ACTIVATE ever reached the ACS, so there is nothing there to stop. The run is closed locally as STOPPED and no command is written — sending one would burn a command sequence for a run the ACS has never heard of and leave the row in STOPPING until 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 stop_reason = USER_PAUSE and the campaign landing in PAUSED instead of NOT_ACTIVE. There is no PAUSE command on the wire in v1 — the ACS answers REJECTED(UNSUPPORTED) to one — so what actually reaches the ACS is an ordinary STOP. The running measurements end; they are not suspended.

Resume is a plain POST /FCC/activate, which starts a NEW run. New fcc_activation row, new seq, new idempotency key, new command_seq, and a fresh settings snapshot taken at that moment. Nothing resumes the paused run, and nothing continues its iteration count. For a compliance report that means a paused-and-resumed campaign is two runs, not one interrupted one.

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 state FccStopService.LIVE_STATES deliberately excludes, because a second command would burn a command sequence to repeat an instruction the ACS is already carrying out. The log line reads activationIds=[] 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 the ACTIVATION_STOPPED the ACS reports when they drain from reaching the campaign, so a stopped-after-pause campaign cannot slide from NOT_ACTIVE into STOPPED;

  • any pending instant is cleared, which is this transition’s only effect — see why CLEAR_SCHEDULED_AT and not STOP_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 — 503 degraded, then 409 conflict, then 404 not found, then 400 — and not the first one encountered. A mixed batch is summarised by its worst outcome because that is what decides whether a retry can help: one id hitting an unavailable store means "retry later", whatever the others did. An FccException subclass that is not in that ranking is treated as the most severe, so a failure nobody has classified is never reported as a mere 404.

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

fcc_test, fcc_test_config, fcc_test_setting_speed, fcc_test_setting_latency, fcc_test_device

Deleted

The campaign’s definition — the rows the operator authored. That is what they are asking to remove.

fcc_activation

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.

fcc_acs_outbox

Kept

It still has work to do. The STOP written a few statements earlier has not been sent yet — FccOutboxDispatcher picks it up after the commit — and deleting the row would leave the ACS measuring for a campaign that no longer exists, which is exactly what stopping it first was for.

fcc_device_state, ClickHouse fcc_result_speed / fcc_result_latency

Kept

All three are keyed on activation_id, never on test_id. That key stays resolvable because the activations stay, so the per-device status and the results of a deleted campaign’s runs remain readable and joinable.

fcc_audit

Kept, and one DELETE row appended

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

completed

FINISHED — the device finished its measurement.

pending

SENT + WAN_CHECK — the run is waiting on it. The WAN sample is a step of the dispatch, not an outcome of it.

offline

OFFLINE — the ACS could not reach it.

notSent

INITIAL + STOPPED — nothing was sent to it: the run has not reached it yet, or it was stopped before it did.

failed

ERROR, and nothing else. Only a real failure is counted here.

skipped

SKIPPED + CROSS_TALK — the measurement did not happen and nothing failed.

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.

  • CROSS_TALK is counted as skipped, never as failed. The line was already busy above the configured WAN threshold when the iteration ran, so the iteration measured the neighbour’s traffic rather than the service and carries no usable figure. Counting it as a failure would report a compliant line as breaching; counting it as completed would claim a measurement nobody took. "The measurement did not happen and nothing failed" is exactly what skipped says.

  • STOPPED is counted as notSent, never as failed. The operator halted the run between two steps, before the device’s WAN sample or diagnostic was ever sent — which is what Group Update’s own NOT_ACTIVATED means: a device the update never reached.

Group Update’s answer is a projection too: its CONDITIONAL state is counted in none of the six. The shape is not a claim that six states exist.

What is lost is lost only in this answer. All nine states stay in fcc_device_state, every transition is logged, and CROSS_TALK is a measurement status in its own right on POST /FCC/reports/results and in The Pass/Fail Formula. See the wire dictionary for the nine.

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 activationId this 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_state is keyed on activation_id alone, 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 is FccTestAccessResolver and 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

limit

How many PENDING rows to read, oldest first. null, 0 or negative means the configured ft.fcc.dead-letter.resend-batch-size (default 500) — which is also the ceiling. A larger requested value is silently clamped to it: one call must not be able to pull an unbounded backlog into the in-memory buffer, which would re-trigger the very overflow the dead letter table exists to absorb, and would do it with rows that were parked by that same overflow.

resent

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

reason = CLICKHOUSE_UNAVAILABLE

Deserialized from the JSON payload and replayed through FccResultIngestService.accept (with acsNode = "dead-letter-resend"), then marked RESENT. Because the payload is the wire form, the resend runs the same mapping a live message runs and cannot drift from it.

Payload cannot be read

Marked DISCARDED with the parse error, and counted (fcc.result.discarded). It can never succeed, and a poisonous row that stayed PENDING would be read forever and would keep the head of the oldest-first read.

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 received_at and handed to the ingest path one group per instant, rather than as one batch. Both the after_stop flag and the "too late for the run at all" rule are computed from that instant (The After-Stop Window), so replaying a measurement that arrived at 10:00 under the clock of a 14:00 resend would judge it late against a run stopped at 09:50 — and park it right back, now as an excluded LATE row, while marking the original RESENT. Every parked measurement of every stopped run would be converted from recoverable to excluded by the very call meant to recover it, and the operator would be told the data was recovered at the exact moment it was thrown out.

The resend is itself refused with 503 while the result store cannot take rows (FccResultStorageGate.require(…​), called from FccAdminController — a private method through M2/M3, moved into its own component in M4 once the same rule was needed by all four reporting endpoints too, see Component View).

It is the same FccDegradedException / FccErrorCodeEnum.SERVICE_DEGRADED (9503) that POST /FCC/activate answers with, and here the damage is immediate rather than hours away: the ingest path a resend feeds is the very path that parked these rows, so with the store still unavailable every replayed measurement is parked straight back as a new row, the table multiplies, and the operator is told the rows were recovered — because RESENT means "handed back", not "stored". SCHEMA_BEHIND closes the same gate for the same reason: a row resent into a table Flyway has not finished migrating is no better stored than one resent into an unreachable database.

There is no resend poller. This endpoint is the only trigger; nothing drains fcc_result_dead_letter on a schedule, and there is no attempt ladder. A CLICKHOUSE_UNAVAILABLE backlog therefore sits there until an operator calls it — after the outage is over, which is the point: calling it while ClickHouse is still down simply parks the rows again. Calling it more than once is safe: a RESENT row is not read again, and the ingest path’s own natural-key de-duplication catches an overlap.

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. /FCC/devices/resolve and /FCC/devices/count each carry two handler methods on the same path and method, told apart by consumes — see Why One URL Serves Both JSON and an Upload. OpenAPI cannot express two operations for one path+method, so springdoc merges them into one operation with two request content types and keeps only one of the two @Operation descriptions. docs/openapi/ft-fcc-service.yaml carries a hand-merged description on both, stating the JSON rules and the multipart rules, because a frontend reading only the generated text would never learn that the upload forces sourceType=FILE. Regenerating the file re-introduces the loss; see docs/openapi/README.md.

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

type

SPEED | LATENCY, required

Required, and it selects the tier presets — not the models. See The Test Type Does Not Narrow the Model Catalog.

onlyOnline

Boolean

true hides a model none of whose devices are currently online. The counters stay the real figures either way: shrinking them to the online ones would make the catalog and the resolved fleet describe different things.

domainIds

List<Integer>

Narrows within the caller’s scope and can never widen it — see The Empty Intersection Selects Nothing, Never Everything.

Response (FccCatalogResponse):

Field Type Notes

manufacturers

List<FccCatalogManufacturerItem>

manufacturerId, manufacturer, deviceCount, models. deviceCount is the sum over models, computed in Java, so the tree and its leaves cannot disagree. The buckets are grouped by NAME, not by manuf_id — see The Catalog Is Grouped by Manufacturer NAME, and manufacturerId May Be Null, which also explains when manufacturerId is null and what a caller must do about it.

tiers

List<FccTierPresetItem>

label, dlKbps, ulKbps. See Tiers Are Served, Not Hard-Coded in the UI.

maxDevicesPerTest

Integer

ft.fcc.max-devices-per-test, served so the wizard can refuse a selection before it asks the backend to resolve one. It is not domain data, and is returned even when the caller can see no domain at all.

FccCatalogModelItem:

Field Type Notes

modelId

Integer

ftacs.product_class.id. This is what this product calls a model: it is what fcc_test_device.model_id snapshots and what fcc_model_capability is keyed by. See The ACS Schema This Service Reads.

model

String

product_class.model.

productClassGroupId

Integer

product_class.group_id.

protocol

String

FccCpeProtocolEnum constant name, taken from product_class_group.protocol_id — the only place the catalog carries a protocol, since product_class has no such column in the shipped ACS DDL whatever the api-orm entity maps. null when the model has no group row at all, which is the honest answer rather than the TR069 a null protocol_id would otherwise be coerced to; the two cases are told apart by selecting g.id alongside it. It is a hint and not the authority — what decides whether a device can be measured is cpe.protocol_id, per device. See product_class_group.

deviceCount, onlineCount

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.

connectionsDlMax, connectionsUlMax

Integer

From fcc_model_capability. null means unconstrained, not zero and not one — the table is an allow-list of known limits, not a registry of every model, so the UI must not narrow the operator’s input on the strength of an absent row. See fcc_model_capability.

supportsTimeBased

Boolean

From the same row, false when there is none. This is the TIME_BASED opt-in and a UI should gate the choice on it: PUT /FCC/item/details refuses a time-based configuration whose fleet contains any model that is not recorded as supporting it (testMode: FILE_BASED and TIME_BASED, and What the Second One Costs). It used to be "recorded for v2 and not consulted by anything", which stopped being true with contract 1.6.

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

type missing

400 / 9400

Bean validation, "type must not be null". There is no default: the two types serve different tier lists, and guessing would offer an upload rate for a ping.

Identity contract not satisfied

403 / 9401

See Identity Contract. The identity filter uses IDENTITY_MISSING (9401), not DOMAIN_DENIED (9403) — see [two-403-codes].

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.

manufacturerId is reported only when every model of the bucket agrees on it, and null has a specific instruction attached.

Grouping by name means a bucket can legitimately hold rows of two different manuf_id values, or of none at all. Reporting one of them would tell the caller that filtering by condition.manufacturerIds selects this bucket — and it would select part of it, or none.

So manufacturerId: null means "this manufacturer is identified by name alone", and a caller narrowing a selection to it must send condition.manufacturers with the manufacturer string, never condition.manufacturerIds.

manufacturer: null is its own, separate case: the one bucket of models the ACS records no manufacturer for at all. Such models are never merged into a named bucket — "the ACS records no manufacturer for these" is an honest answer the operator can act on, whereas filing them under a neighbour’s name is a lie about whose hardware they are.

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 MANUAL constant in FccTierPresets and there never will be one. The list is an offer, not a whitelist: a subscriber’s provisioned tier is whatever their contract says, and an installation will meet plans nobody put in this list. The backend accordingly validates a tier for its sign only (FccTestValidator.requirePositiveWhenPresent) and never against the presets — see the PUT /FCC/item/details validation table. A validator that matched the preset list would refuse a perfectly ordinary 300/30 plan and leave the operator with no way to measure it at all.

Zero is still refused, and that is not inconsistency: a tier of zero makes every measurement infinitely over-performing and speedSuccessPercent meaningless.

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

sourceType

CONDITION | INDIVIDUAL | FILE, required

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.

condition

FccDeviceConditionRequest

Read for CONDITION, and required there. For INDIVIDUAL and FILE it is stripped down to onlyOnline and domainIds — see Why One URL Serves Both JSON and an Upload, which explains the same rule for the upload.

serials

List<String>

Read for INDIVIDUAL, and required there. Trimmed, de-duplicated case-insensitively, the caller’s own spelling kept — the identical normalisation an uploaded file gets, so the two source types cannot disagree about what "the same serial twice" means.

pageNumbers

Integer, 1-based

Defaults to 1 if absent or < 1.

pageSize

Integer

Defaults to ft.fcc.resolve.default-page-size (100), capped at ft.fcc.max-devices-per-test and not at a page ceiling of its own: the wizard must be able to ask for the whole resolved fleet in one call in order to save the test, and the read is bounded by that same limit anyway. A second knob could only be set to a value that makes saving a full fleet impossible.

FccDeviceConditionRequest — every field optional, present ones combined with AND, no two fields ever OR-ed:

Field Type Predicate

manufacturerIds

List<Integer>

pc.manuf_id IN (…​).

manufacturers

List<String>

UPPER(m.name) IN (…​), plus the product_class_group.manufacturer_name fallback for a product class whose manufacturer row is missing. UPPER on both sides because the column’s collation is case-insensitive on MySQL and case-sensitive on Oracle.

modelIds

List<Integer>

pc.id IN (…​) — the id form the catalog offers.

models

List<String>

pc.model IN (…​), exact, not upper-cased — unlike manufacturers. Flagged as an asymmetry in the condition set.

productClassGroupIds

List<Integer>

pc.group_id IN (…​).

firmwares

List<String>

c.firmware IN (…​), exact.

protocols

List<String>

FccCpeProtocolEnum names, never ordinals — a constant inserted into the ACS enum must not re-point a filter the UI saved. An unrecognised name is a 400 listing the allowed set. Asking for TR069 also matches rows whose protocol_id is NULL; see the protocol_id IS NULL arm.

serialContains

String

c.serial LIKE '%…​%' ESCAPE '!'. Wildcards in the value are escaped — it is a substring the operator typed, not a pattern they write.

onlyOnline

Boolean

TRUE adds c.is_online = 1. FALSE and absent are the same thing — "do not filter" — because a fleet of provably offline devices is not something the wizard ever asks for.

domainIds

List<Integer>

Narrows within the caller’s scope — see The Empty Intersection Selects Nothing, Never Everything.

Response (FccDeviceResolveResponse):

Field Type Notes

items

List<FccResolvedDeviceItem>

One page, sliced in memory from the ordered full match. cpeId, serial, modelId, manufacturer, model, domainId, online, protocol, supportedByAcs, firmware.

pageDetails

FccPageDetails

The same currentPage / pageItems / pageSize / totalItems / totalPages envelope POST /FCC/items uses. totalItems is the whole match.

totalCount, onlineCount, unsupportedProtocolCount

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.

countsByModel

List<FccModelCountItem>

modelId, manufacturer, model, deviceCount, onlineCount — again over the whole match. A device with no product class gets a bucket with modelId, manufacturer and model all null, so the per-model figures always add up to totalCount; see A Device With No Product Class Is a Member of the Fleet Like Any Other.

notFoundSerials

List<String>

See notFoundSerials and duplicateSerials Are Two Different Defects.

duplicateSerials

List<String>

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

sourceType missing

400 / 9400

Checked twice: by bean validation on the request record, and again in FccDeviceResolveService.resolveFleet. The duplication is deliberate — every branch of the resolver switches on it, so a null that slipped past the annotation is an NPE and an HTTP 500, a malformed body answered as a server fault. The constraint states the contract; the service check is the guarantee, and it holds for every caller including resolveForSave reached through a nested record whose constraints a missing @Valid once skipped entirely. See the field table above for why there is no default.

Any condition list whose entries are all null or blank

400 / 9400

"condition.modelIds holds 3 entries and every one of them is empty; send the values you mean to narrow by, or omit the field entirely, which is what selects every value". Individual nulls and blanks are stripped; a list left with nothing is refused rather than dropped, because dropping it would widen the selection. See A List of Nulls Is Refused, Not Quietly Emptied.

condition.protocols holding entries, none of which names a protocol

400 / 9400

The same rule one level up, in protocolIds: "condition.protocols holds 2 entries and none of them names a protocol; send the protocol names, or omit the field, which is what selects every protocol".

sourceType = CONDITION with no condition block

400 / 9400

"sourceType=CONDITION requires a condition block; an absent one is not a selection of every device". The difference between "the operator has not chosen yet" and "the operator chose the whole installation" is twenty thousand devices, and only one of the two is ever meant by an absent field.

sourceType = INDIVIDUAL with an empty or all-blank serials

400 / 9400

"sourceType=INDIVIDUAL requires a non-empty serials list".

sourceType = FILE whose parsed file yields no serial

400 / 9400

"the uploaded file contains no serial"; an empty upload is "the uploaded serial list is empty" and an unreadable one names the I/O error.

Any of condition.manufacturerIds / manufacturers / modelIds / models / productClassGroupIds / firmwares longer than 1000

400 / 9400

"condition.modelIds holds 1400 values; at most 1000 are accepted". Oracle refuses more than FccDbConstants.IN_CLAUSE_MAX_EXPRESSIONS expressions in one IN. The serial list is chunked instead of refused, because it is sized by the caller’s fleet; a condition list is sized by the catalog the caller picked from, so an oversized one is a malformed request.

More distinct serials than ft.fcc.max-devices-per-test

400 / 9400

FccSerialListParser.normalize, before any read.

condition.protocols naming something that is not an FccCpeProtocolEnum constant

400 / 9400

"protocol=TR-069 is not supported; allowed: TR069, OMA, LWM2M, MQTT, UNKNOWN, USP, MQTT_SN, COAP, TR181, NOT_SET". Note the spelling: TR069, not TR-069.

The selection resolving to more than ft.fcc.max-devices-per-test devices

400 / 9400

See The Selection Limit Is Counted Before Any Row Is Read.

Identity contract not satisfied

403 / 9401

See Identity Contract. The identity filter uses IDENTITY_MISSING (9401), not DOMAIN_DENIED (9403) — see [two-403-codes].

Multipart: no file part

400 / 9400

MissingServletRequestPartException. The message states that file is required and the JSON request part is not.

Multipart: upload larger than spring.servlet.multipart.max-file-size

413 / 9413

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 / 9415

Most often the optional request part sent with no content type of its own — curl’s default for -F — so it is offered as text/plain. The message says to give each part its own Content-Type, application/json for request.

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

All

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 0 when the caller can see no domain — never a count with no domain predicate.

Individual, File

0. The client holds those lists and already knows their size; the server would have to guess. Group Update’s own counter answers zero here too. It additionally counts a File config from its in-memory import store, which this service has no equivalent of — an uploaded file is resolved the moment it arrives.

Condition

The saved condition conditionId names, intersected with the configuration’s own manufacturer and model — which is what Group Update’s Condition branch does too (it ANDs the view’s criteria with getDefaultViewConditions(manufacturer, model, null)), and the same fleet PUT /FCC/item/details stores for that configuration. See sourceType=Condition: The Subset of the Portal’s Conditions This Service Translates. A condition this service does not translate in full is a 400 naming the column key and the operator, never a zero: a zero here is a lie an operator acts on, since the dialog would promise that activating the configuration touches nothing while it touches thousands. To count an inline condition, send it to POST /FCC/devices/count.

absent

Refused, 400. It decides what is counted; guessing it from whichever other field is filled in is how a dialog promises a number nobody asked for.

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 IN list. An absent domain list is exactly the shape that silently becomes a wildcard, and FccDomainScope keeps the three outcomes — unrestricted, these ids, nothing — apart on purpose. isUnrestricted() exists because the obvious spelling of that question, domainIds().isEmpty(), throws for one of them and answers "unrestricted" for another.

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.

PUT /GroupUpdate/devices/import answers an empty body and keeps the resolved list in the caller’s HTTP session for thirty minutes (ImportService: a TTLMap under the session attribute importData, keyed UG-<groupId>). Its device-list screen then pages that stored list, and the save reads the whole of it back out of the session rather than trusting the ids the client returns.

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 ft-iot-webservice, which owns the session and already keeps Group Update’s.

So the fleet travels in the response, the client holds it exactly as it holds a pasted serial list, and PUT /FCC/item/details re-resolves it at save time — the stateless sourceType=FILE path this service already serves. A client that ignores the body still gets its 200, so the shape is a superset of the empty one.

Two further differences from Group Update’s import, both following from what an FCC campaign is:

Group Update Here

Takes manufacturer and model, resolves them to a product-class group, and looks every serial up inside that group.

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 notFoundSerials entry, and an unresolved serial is a hard refusal at save time, so an operator uploading a mixed inventory could not save the fleet they uploaded. It is the same reason the multipart /FCC/devices/resolve strips a model condition — see Why One URL Serves Both JSON and an Upload. To select by a model, send the condition to POST /FCC/devices/resolve.

A serial its lookup does not match is silently dropped from the stored list.

Reported in notFoundSerials, in the operator’s own spelling. The save refuses that same serial, and the file is where they fix it; a fleet quietly shorter than the uploaded list is a compliance report missing subscribers with nothing to reveal it.

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

application/json

resolveDevices / countDevices

FccDeviceResolveRequest.

multipart/form-data

resolveDevicesFromFile / countDevicesFromFile

A required file part, plus an optional request part carrying the same FccDeviceResolveRequest.

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 sourceType = FILE, whatever the optional request part claims, and reads that part for two things only.

FccDeviceResolveService.fromFile rebuilds the request as:

Field Value

sourceType

FILE, unconditionally. A request part saying CONDITION is overwritten, not honoured and not rejected.

serials

The parsed contents of the file part. The serials of the request part, if any, are discarded.

condition

Stripped to onlyOnline and domainIds. Every other field — manufacturerIds, manufacturers, modelIds, models, productClassGroupIds, firmwares, protocols, serialContains — is dropped to null.

pageNumbers, pageSize

Taken from the request part as sent; absent when there is no request part.

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 notFoundSerials, reading as "your list is wrong" when the list was fine and the condition was the problem.

onlyOnline and domainIds survive because neither contradicts a serial list: they narrow it in ways the operator can reason about ("of the serials I uploaded, the ones that are online", "the ones in this domain"), and the second is the caller’s own scope narrowing, which they could not widen anyway.

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, on count and on PUT /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, so FtacsDeviceResolveDao.count falls 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.normalize refuses more than ft.fcc.max-devices-per-test distinct 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

notFoundSerials

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 sourceType=CONDITION, which asks for no serial in particular.

duplicateSerials

Serials that matched more than one device. Every matching device is still in items and still in totalCount.

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; PUT /FCC/item/details refuses.

The same resolution runs on both, through FccDeviceResolveService, and the save path adds exactly one rule (resolveForSave): an unresolved serial is a 400.

Serial condition POST /FCC/devices/resolve PUT /FCC/item/details with a configs[] selection

Matched nothing visible

Reported in notFoundSerials, resolution continues

400. "the selection names 37 serial(s) that match no device visible to you: …​", listing the first 20 in the caller’s own spelling

Matched more than one device

Reported in duplicateSerials, every match returned

Accepted. Every match is stored

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

FccResolvedDeviceItem.protocol

The FccCpeProtocolEnum constant name. A device whose protocol was never recorded reports as TR069 — see the null rule below.

FccResolvedDeviceItem.supportedByAcs

false means the ACS of v1 will answer SKIPPED(UNSUPPORTED) for this device.

FccDeviceResolveResponse.unsupportedProtocolCount

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

TR069

0

Yes. CWMP with a TR-098 data model (InternetGatewayDevice.). The UI calls this one TR-098.

TR181

8

Yes. CWMP with a TR-181 data model (Device.). A currently registered TR-069 CPE is normally this one, not TR069.

OMA, LWM2M, MQTT, UNKNOWN, USP, MQTT_SN, COAP, NOT_SET

1, 2, 3, 4, 5, 6, 7, 100

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 NULL means TR069, 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 own fromOrdinal does 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’s fromOrdinal coerces an unrecognised ordinal to TR069, and ui-backend’s `ProtocolType.fromValue coerces it (and a null) to NOT_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.

FccCpeProtocolEnum.isSupportedByAcs()’s own Javadoc records it: the architecture plan lists CpeProtocolEnum.TR069 hard-coded in the ACS diagnostics path (`FccExecutionProcessor.startFcc) as known debt.

If fcc-core ends up comparing for TR069 alone instead of calling isTrProtocol(), then TR-181 devices come back SKIPPED(UNSUPPORTED) from the ACS while this service has told the operator they are supported — which is the one direction the flag is designed never to fail in. In that case this predicate has to be narrowed to match.

Confirm against fcc-core before the first TR-181 pilot. Nothing in ft-fcc-service can answer it, and nothing here should be changed on a guess: narrowing the predicate pre-emptively would hide from every operator the largest supported half of a modern installation.

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 cpeId, or by a serial in an INDIVIDUAL / FILE selection

It is found and returned, with modelId, model and manufacturer all null. It counts in totalCount and in onlineCount, it can be stored in a fleet, and it is measured like anything else — nothing downstream needs a model to run a diagnostic.

By any model-based condition — modelIds, models, manufacturerIds, manufacturers, productClassGroupIds, firmwares

It is excluded, and silently, because a comparison against NULL is never true. This is the same selection semantics an INNER JOIN had; the LEFT JOIN did not widen any condition, it only stopped the id and serial lookups from losing rows.

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 countsByModel, with a null modelId, and that is what keeps the arithmetic true.

The alternative — dropping the null-model rows from the breakdown — would make the per-model counts sum to less than totalCount, on a screen whose entire job is to tell the operator how many subscribers a campaign covers. An operator reconciling 1 198 against 1 200 has no way to find the missing two, and no reason to suspect the grid rather than their own selection.

The null bucket is also what fcc_test_device.model_id being nullable is for, and validateConnectionCeilings filters the null model ids out before asking fcc_model_capability for a ceiling — an unknown model constrains nothing, exactly as a known model with no capability row does (The Capability Ceiling Is Refused, Not Clamped).

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 fcc_model_capability row

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 validateRunnable is what refuses to activate one.

Neither connectionsDl nor connectionsUl given

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:

speed.downloadConnections=4 exceeds 2, the lowest ceiling among the models of this
fleet, imposed by model id(s) 402; lower the value or run those models as a
test of their own. The value is not reduced automatically, because a compliance
report has to state the number of connections the run was actually measured with

"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 FccCommandDto carries one FccActivationConfigDto and one device list per activation;

  • fcc_sa_activation is unique on both activation_id and idempotency_key, and FccActivationService.persistActivation treats 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_group carrying 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

All

Every device of the named manufacturer and model the caller can see, as a CONDITION selection matched by name and case-insensitively — which is how the catalog groups them, and what Group Update’s own All counts. Both are required: without them it would select the caller’s whole installation.

Individual

selectedDevices, as cpe ids. An id the caller cannot see or that does not exist fails the save rather than shortening the fleet.

File

The same as Individual. The client holds the imported list — from the wizard or from PUT /FCC/devices/import — and sends the ids it resolved.

Condition

The saved condition conditionId names, read from the portal’s iotw schema and intersected with the configuration’s own manufacturer and model — the same fleet POST /FCC/activate/devices/countByGroupConfigs counts. Evaluated once, at save time, and stored as cpe ids like every other source: a condition re-read at activation would dispatch to a fleet nobody approved. Only a subset of the portal’s condition vocabulary is translated, and the rest is a 400 that names what it could not translate — see sourceType=Condition: The Subset of the Portal’s Conditions This Service Translates.

unselectedDevices is subtracted after resolution, and the fleet ceiling is checked before the subtraction.

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 FccTestValidator.withoutUnselected, after the selection has been resolved, so that one code path resolves a fleet for the wizard, for the save and for the exclusions.

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 selectedDevices. An id the selection never contained (a device re-provisioned since the screen was opened, or one of another model) means the operator wanted it out, and it is out; failing the save would refuse a campaign whose fleet is exactly what they asked for. Excluding everything is legal and leaves an empty fleet, which is a legal draft: a campaign saved empty by accident is visible on the screen that saved it, unlike a form the operator has to reconstruct.

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 conditionId is stored beside the fleet so the read can answer the condition’s name and a later edit can show the operator which condition the frozen fleet came from.

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

serial

cpe.serial

Equal, NotEqual, Like, NotLike, IsNull, IsNotNull

firmware

cpe.firmware

the same six

manufacturer

product_class_group.manufacturer_name

Equal, NotEqual — the only two the portal offers on it

model

product_class_group.product_class

Equal, NotEqual

status

cpe.is_online

On, Off — which carry no value

domainId

cpe.location_id

Equal, NotEqual, InHierarchy, IsNull

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:

  • manufacturer and model are the product group's columns. FieldPath points at productClass.productGroup.manufacturerName and .model, and ProductClassGroupEntity maps the field called model onto the column called product_class. There is no product_class_group.model. This service’s own wizard filter compares product_class.model and 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.

  • status uses On/Off with no value, never Equal "true". The column’s filter type is OnOrOff and the portal renders isTrue/isFalse; both exclude NULL, so a device whose is_online was never written is neither on nor off. Off is therefore = 0 and not = 0 OR IS NULL.

  • Equal with no value is a plain IS NULL, and IsNull is blank-or-null. The portal answers cb.isNull for the first and isBlank for the second, so an empty string in cpe.firmware satisfies IsNull and not Equal "". NotEqual with 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 (QueryViewHelper trims inside its hasStringValue branch, so a value of spaces reaches LIKE untrimmed), and Like is a contains with the value not escaped — a % an operator typed into a saved condition is a wildcard there. A string NotEqual is null-tolerant (<> value OR IS NULL); a domain NotEqual is not.

  • domainId does not go through the generic path at all. ViewService.getPredicateFromDomain handles it: Equal on the super domain (0 or -1) matches 0, -1 and NULL; InHierarchy on the super domain matches everything (cb.and() with no arguments); InHierarchy on a real domain is the branch under it, read from isp by name with the statement DomainRepository.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 WHERE as the caller’s own domain predicate from FccDomainScope, 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 null conditionName and a readable campaign.

  • ft.fcc.iotw.enabled=false refuses every conditionId with 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 (default true) 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

type

Daily or None, required when the block is present. None is the explicit spelling of "runs once" — what an absent block has always meant — and it wins over the rest of the block: {"type": "None", "reactivationCount": 5} stores a one-shot and the 5 is ignored rather than refused, because the operator chose None on the screen and the 5 is what the form still held. The read answers {"type": "None"} and not null for a one-shot campaign, so a UI always has an object to bind its recurrence control to. Daily is the only repeating unit the engine can run — FccReactivationService plans the next window by adding days. A one-value enum rather than an implicit unit: repeatEvery: 2 means "every two days" because the type says Daily, and a later Weekly must not re-point every rule already stored.

repeatEvery

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.

startsOn

The anchor the slots are counted from: run n opens at startsOn + n * repeatEvery days. Absent means the previous run’s own start, which is what the planner measured from before. An anchored campaign stays on its grid however late a run happened to open; counting from the previous run is drift-free only while every run opens on time.

ends

After says the operator chose to bound the campaign by reactivationCount. Absent says they did not say — the count bounds it either way. ends=After with no count is a 400: "after" what is otherwise unanswered.

reactivationCount

How many runs the campaign owes after the first. This is the existing fcc_test.reactivation_days column, which has always held a count and never a duration. Range 0..9 — see reactivationCount Is 0..9.

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

fcc_model_capability

Any caller

X-FT-Domain-Scope: ALL required, 403 otherwise

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.

fcc_domain_setting

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 ALL-only rule on model capabilities is the whole access model there, and it has to be, because this product has no role model at all (decision R2).

Every caller ui-backend lets through may do everything its domain scope permits — there is no permission bit anywhere in this service that could express "installation administrator". Domain scope is consequently the only boundary that exists.

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 (FccCatalogModelItem.connectionsDlMax), and hiding the admin view of numbers the wizard prints would protect nothing while making the screen unusable for the operator who has to report the problem.

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

POST /FCC/admin/domain-setting/details for an invisible domain

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.

PUT /FCC/admin/domain-setting for an invisible domain

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.

DELETE /FCC/admin/domain-setting naming an invisible domain

200, with the refusal in that id’s result item

The bulk envelope wins here, exactly as it does for DELETE /FCC/items: every id carries its own outcome. The reason text is the forbidden one ("domainId=99 is not among the caller’s visible domains") and not the missing one, for the same "a delete names its domains explicitly" argument — it is the status that is 200, not the semantics that changed.

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

POST /FCC/admin/model-capability

FccModelCapabilityListRequest: modelIds (optional, capped at 1000), pageNumbers (1-based, default 1), pageSize (default 25, capped 500)

FccPage<FccModelCapabilityItem>, sorted by modelId ascending

POST /FCC/admin/model-capability/details

{modelId} required

FccModelCapabilityItem, or 404

PUT /FCC/admin/model-capability

FccModelCapabilityUpsertRequest

FccIdResponse — the row’s surrogate id

DELETE /FCC/admin/model-capability

{modelIds} required, non-empty, no nulls

FccModelCapabilityDeleteResponse: results[] of {modelId, deleted, error}

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

modelId

Integer, required

Must exist in ftacs.product_class, checked by FtacsCatalogDao.modelExists on every write. This is the referential check the table cannot have as a foreign key — the referenced table is in a schema this service may only read (fcc_model_capability) — and it is worth making by hand: a row written against a mistyped id constrains nothing, forever, and is invisible, because every lookup of it is by an id no fleet will ever produce. The operator would see their limit saved and then watch campaigns ignore it. 400: "modelId=999999 is not a model of this installation; …​".

connectionsDlMax

Integer

1..4 when given, judged by the very method PUT /FCC/item/details uses. null CLEARS the ceiling and means unconstrained — it is not "leave the stored value alone", because a PUT states the whole row.

connectionsUlMax

Integer

The same for the upload direction.

supportsTimeBased

Boolean

Stored, and acted on since contract 1.6: setting it true is what opts a model into TIME_BASED, and PUT /FCC/item/details refuses a time-based configuration over any model that has no such record (testMode: FILE_BASED and TIME_BASED, and What the Second One Costs). Absent is stored as false, and a model with no capability row at all counts as not supporting it — so the column is an opt-in and never a restriction. The WARN that used to be logged here is gone with the thing it warned about: setting it true now does change what runs, which is what the operator expected it to do.

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:

  1. Two operators save the same model at the same moment. Both findByModelId return empty.

  2. Both take the create branch and both INSERT.

  3. 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 INSERT leaves its transaction marked rollback-only. Catching the violation inside the same transactional method would therefore build a perfectly good response and then have the commit throw UnexpectedRollbackException — a 500 arriving after the work appeared to succeed, which is strictly worse than the 500 it was meant to prevent.

Self-invoking a @Transactional method does not help either: it never reaches the Spring proxy, so the "second transaction" would be the same rollback-only one. This is the same trap Both Admin Deletes Are Per-Item Transactions, Not One documents for the deletes, reached from the opposite direction.

A consequence of the same rollback: the abandoned attempt leaves no fcc_audit row, because the audit write joined the transaction that rolled back. One accepted PUT is still exactly one audit entry.

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

POST /FCC/admin/domain-setting

FccDomainSettingListRequest: domainIds (narrows within scope), pageNumbers, pageSize

FccPage<FccDomainSettingItem>, sorted by domainId ascending, narrowed to the caller’s scope

POST /FCC/admin/domain-setting/details

{domainId} required

FccDomainSettingItem, or 404 — both when the domain has no row and when it lies outside the caller’s scope

PUT /FCC/admin/domain-setting

FccDomainSettingUpsertRequest

FccIdResponse; 403 for a domain outside the caller’s scope

DELETE /FCC/admin/domain-setting

{domainIds} required, non-empty, no nulls

FccDomainSettingDeleteResponse: results[] of {domainId, deleted, error}, HTTP 200

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

urlDl, urlUl

requireDiagnosticUrlWhenPresent — absolute, scheme http / https / ftp, with a host.

thresholdDl, thresholdUl, speedSuccessPercent, latencySuccessMs

requirePositiveWhenPresent.

latencyHost

Stored as given. It is the ISP’s default IPPing.Host; it does not make the field optional on a test — validateLatencySetting still refuses a latency campaign with no host from either level. What a domain default buys the operator is not having to retype the ISP’s own target; what it must not buy them is a campaign that pings nothing.

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 fcc_domain_setting leaves nothing behind in the campaigns it goes on to shape. A campaign saved after an edit looks exactly as though the operator had typed those numbers themselves, and the entity_type = 'DOMAIN_SETTING' audit row is the only record that they did not.

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/details reads back the resolved values, not what was sent. A body that omitted speed.downloadUrl comes 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 fail validateRunnable later with no indication that a default was available. When no level has a value the field is stored and read back as null, 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 requireDiagnosticUrlWhenPresent an operator’s own would be, and a latency.testHost supplied 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, FccReportScopeResolver, and one predicate builder, FccResultQueryDao, for all four. Every endpoint resolves the same FccReportFilters body into the same FccResultFilter, and every read — the run list, the measurement page, the aggregate header and the export stream — is built from that one filter. That is a correctness property, not an optimisation: a second resolution or a second copy of the predicates is how the number above the grid, the rows in it and the file an operator downloads come to describe different selections. See The Report Read Path.

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

CROSS_TALK is its own status, never a kind of FAILED

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 SUCCESS, FAILED and OTHER.

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.

afterStop is a regulatory filter, not a delete

A measurement that arrived after a run’s STOP is kept in the store exactly as it was written — it is a real measurement of a real device — and is excluded from a report by filtering afterStop=false, never by removing anything. See The After-Stop Window.

Thresholds in every row come from the fcc_activation snapshot, never the editable campaign

tierDl/tierUl, speedSuccessPercent, latencySuccessMs and the rest of the 26-column block are read off the run’s own frozen settings (The fcc_activation Settings Snapshot) — what a run was actually dispatched with. Reading them off fcc_test instead would rewrite a finished run’s parameters every time an operator edited the campaign afterwards, exactly the regulatory-reproducibility failure the snapshot exists to prevent.

Two source dictionaries, and the reporting filter speaks the ClickHouse one

A measurement is ACS_STREAM or LEGACY_ETL; a run is ACS_STREAM or LEGACY. POST /FCC/reports/list enumerates runs, but the source its filter takes — and the value it hands back in FccReportListItem.source — is the measurement dictionary, translated exactly once by FccSourceDictionary. Sending LEGACY (the run spelling) as a filter value on any of these four endpoints is a 400: FccResultSourceEnum has no such constant, so Jackson refuses the body before a filter is ever built — it is not silently read as "no filter" and it is not an empty result. See Two Different source Dictionaries.

All four answer 503 / 9503 while the result store cannot be read

FccResultStorageGate.require(…​) runs before anything else on every one of the four, and since M4 that gate distinguishes why: details.reason on /actuator/health says whether ClickHouse is unreachable or merely schema-behind — see the three ClickHouse states. An empty page would be indistinguishable from "this campaign measured nothing", which is the one statement a compliance report must never make by accident — so the honest answer is 503, not an empty result.

POST /FCC/reports/list

One page of runs, each with its five result counters. FccReportController.list → FccReportService.list.

Request (FccReportListRequest):

Field Type Notes

filters

FccReportFilters

See [report-filters].

sorts

List<FccSort>

Whitelisted by FccReportSortFieldEnum: created, seq, state, deviceCount, windowStartAt, startedAt, finishedAt, stoppedAt. An unrecognised field is 400, listing the allowed set. See [report-list-default-sort].

pageNumbers

Integer, 1-based

Defaults to 1 if absent or < 1.

pageSize

Integer

Defaults to 25, capped at 500.

FccReportFilters, shared by all four endpoints:

Field Type Notes

domainIds

List<Integer>

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 testId/activationId is also given. Once a campaign is named, the computed scope is used only for that authorization check and is not additionally applied as a per-row predicate on the measurements — a fleet may legitimately hold devices of sub-domains of the campaign’s own, and filtering them out would hide part of a run the caller is entitled to read whole.

testId

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.

activationId

Long

One run. Must belong to testId when both are given, or the request is 404 ("no activation with id=…​ for test id=…​").

type

SPEED | LATENCY

Reads the one table when given, the UNION ALL of both when absent.

manufacturer, modelIds

String, List<Integer>

Resolved through the frozen fleet fcc_test_device, never through live ftacs.cpe — see Model and Manufacturer Are Resolved Through the Frozen Fleet. Both given at once narrows to models satisfying both, not their union. Imported (LEGACY_ETL) rows are reachable by this filter only where the legacy ETL wrote a fleet snapshot for its anchor campaigns — see the IMPORTANT block there.

from, to

Instant

Bounds on started_at, inclusive — the instant the device measured. See The Period Predicate Is Two Predicates.

source

ACS_STREAM | LEGACY_ETL

The measurement dictionary. See Rules Shared by All Four Endpoints.

statuses

List<SUCCESS|FAILED|CROSS_TALK|OTHER>

CROSS_TALK is never implied by FAILED.

afterStop

Boolean

true only measurements after STOP, false only those before, absent no filter. On POST /FCC/reports/list it narrows the five counters of a run but never removes the run itself from the page — see [report-list-afterstop-not-applicable].

cpeId, serial

Integer, String

One device, resolved to the serial(s) it was frozen under in fcc_test_device — the result tables know a device by the serial it carried when measured, not by a live cpeId.

afterStop narrows measurements, and a run has no single afterStop value of its own. POST /FCC/reports/list accepts the field on the shared FccReportFilters body like every other endpoint, and FccResultQueryDao applies it wherever it reads result rows — which affects this endpoint’s five counters (a run whose only measurements are all after-stop reports zero everywhere except whichever counter they fall under) but never removes the run itself from the page. Only POST /FCC/reports/results and POST /FCC/reports/export, which page and stream measurements, can exclude individual rows by it.

Response (FccPage<FccReportListItem>):

Field Type Notes

activationId, seq, testId, testName, domainId, type, state

testName and domainId are null for a run whose campaign has since been deleted — DELETE /FCC/items removes fcc_test and keeps fcc_activation on purpose, see Deletion Is a UI Removal, Not an Erasure.

source

ACS_STREAM | LEGACY_ETL

Translated from the run’s own ACS_STREAM | LEGACY by FccSourceDictionary, so the value shown is a value the next request may filter by — see Rules Shared by All Four Endpoints.

windowStartAt, windowEndAt, startedAt, finishedAt, stoppedAt

Instant

Four of these five are nullable — see [report-list-default-sort].

deviceCount

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.

tierDl, tierUl, speedSuccessPercent, latencySuccessMs

From the fcc_activation snapshot — see Rules Shared by All Four Endpoints.

resultTotal, resultSuccess, resultFailed, resultCrossTalk, resultOther

Long

One grouped SELECT …​ GROUP BY activation_id, status per result table over the whole page, never one count per run — the store holds tens of millions of measurements, and a count per run would be a scan per run, the same decision FccDeviceStatusService already documents for its own per-device summary.

The default sort is created DESC, and it is the only one of the eight sortable fields that is never null.

Four of the others — startedAt, finishedAt, stoppedAt, windowStartAt — are nullable: a run that has never started has no startedAt, one that has not been stopped has no stoppedAt. MySQL and Oracle place NULL at opposite ends of an ordered result by default, so sorting by one of those four columns groups the runs that have no such instant differently depending on which database is under the installation — a difference a caller has no way to see coming from the request alone. created has no such column-dependent surprise, which is why it is the default and not merely the first alphabetically.

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 sorts field on this request, and that is deliberate, not an oversight.

The order is fixed at (started_at, activation_id, serial, direction, iteration) — the measurement instant followed by the natural de-duplication key the result tables document (fcc_result_speed) — because paging a store of tens of millions of rows by LIMIT/OFFSET needs a total, unique order. Anything less repeats a row on one page and silently drops it from the next: two requests for page 1 and page 2 of an ambiguous order are not guaranteed to partition the selection at all. The tail of the fixed order is exactly the natural key that makes rows unique in the first place, so the order is both total and free — it costs nothing beyond what de-duplication already requires the columns to provide.

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

speed (FccReportSpeedBlock)

type = SPEED

speedKbps (Kbps, Double), totalBytes, testBytes, connections, bom, eom.

latency (FccReportLatencyBlock)

type = LATENCY

valAvg, valMin, valMax (milliseconds, nullable — a ping that lost every packet has no average, and rendering it as 0 would be indistinguishable from a sub-millisecond round trip), packetsSent (successCount + failureCount, what the device reports having done), packetsReceived, repetitions / dataBlockSize (what the device actually ran with, contract 1.5; null for a row written before it).

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

total, success, failed, crossTalk, other

Long

crossTalk is its own counter — see Rules Shared by All Four Endpoints.

deviceCount, activationCount

Long

Distinct serials / runs across the whole selection, over the union rather than per table, for the reason above.

firstStartedAt, lastStartedAt

Instant

null when the filter selects nothing.

speedAvgKbps, speedMinKbps, speedMaxKbps

Double

In Kbps — see Rules Shared by All Four Endpoints.

latencyAvgMs

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.

latencyMinMs, latencyMaxMs

Integer

This operation creates nothing and stores nothing, and repeating it is free and always safe. There is no report entity anywhere in ftfcc — the reporting half of M4 added no table at all (ftfcc Schema) — so FccReportSummary is the entire product of this call, recomputed identically by the same filter every time. The downloadable artefact is produced by POST /FCC/reports/export; the runs it summarises are enumerated by POST /FCC/reports/list.

The verb changed from PUT to POST, and the direction of that change is what a reader holding an older client needs to know. The M4 Jira and SDD source material specified PUT, an earlier revision of this section defended it, and the cross-repo contract (00-common-contract.md section 7) specifies POST as of version 1.5.2 — so a client, or an OpenAPI snapshot, that still says PUT is behind this service rather than ahead of it. The reason is the paragraph above. PUT states that a resource is being created or replaced at the URI it addresses, and this call creates nothing, changes nothing and stores nothing: there is no report entity for it to address, the schema list of twelve tables is closed, the downloadable artefact is produced by POST /FCC/reports/export, and the runs are enumerated by POST /FCC/reports/list. That misstatement is not cosmetic — proxies, retry logic and caches are entitled to treat a PUT differently from a read, and this is a read.

POST rather than GET for the same reason as the other three. The filter body ([report-filters]) is far too large to travel as a query string, so every reading endpoint in this service is already POST-RPC; generate now has the shape its three siblings have, which is also what makes "one filter resolver for all four" a statement about the wire and not only about the Java. The correction was made at this point because the frontend has not been started yet: there is no client to migrate, and reversing the decision later would be one annotation on FccReportController.generate.

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 FccReportService.generate and this section now agree with the contract itself, not with an interpretation of it.

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

XLSX

application/vnd.openxmlformats-officedocument.spreadsheetml.sheet

CSV

text/csv;charset=UTF-8

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 RowCallbackHandler into the writer and out of the response socket one at a time, on a fetch size of 5000 rather than a page, because ui-backend already streams this path through to the browser unchanged and a buffer on this side would remove the only reason the endpoint has this shape. This is also why Apache POI is not used: XSSFWorkbook builds the whole book in memory and SXSSFWorkbook — the streaming variant — spools the sheet to temporary files, which is exactly what is being avoided; FccXlsxStreamWriter assembles a minimal five-part OOXML package by hand instead, with no sharedStrings.xml (every text cell is an inline string) and no styles.xml (instants are ISO-8601 text in UTC, not Excel date serials, because a serial carries no time zone and a regulatory extract needs one unambiguous instant). Numbers stay numbers, so speed and latency columns can still be summed in the spreadsheet.

Content-Length is never set, and that is the point of the endpoint rather than an omission. The size is unknown until the last row has been read; computing it in advance would mean counting the rows twice or building the file first, both of which reintroduce the buffering the endpoint exists to avoid.

Every refusal happens before the first byte. The result-store gate is checked first: FccReportController.export calls fccResultStorageGate.require(NO_EXPORT) before prepare() is ever invoked — not inside it. Body validation, the domain scope and the named campaign or run check then run inside FccReportExportService.prepare, in one @Transactional(readOnly = true) block, before the StreamingResponseBody is even constructed — the one reporting endpoint of the four that states its own transaction boundary explicitly, because FccReportScopeResolver makes several consecutive reads of ftfcc and ftacs and the export is otherwise the only one of the four not already wrapped in a read-only transaction by its caller. The transaction covers exactly prepare() and nothing beyond it: StreamingResponseBody runs later, on another thread, after the container has already begun the response, and a transaction spanning that would hold one of twenty ftfcc connections for the minutes a large extract takes.

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 prepare(). A failure that happens during the stream (ClickHouse drops the connection mid-read, the client closes the tab) can only close the socket and write a structured log line (fcc export aborted / fcc export failed, naming the identifiers and the row count already written); the client sees a truncated file with no error anywhere in it. See the export-truncated row of Troubleshooting.

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 /reports/export, 200 is sent with the headers before the body streams — see above.

400

The body fails bean validation, or names an unsupported enum constant on the wire — including source=LEGACY (the run spelling, see Rules Shared by All Four Endpoints) — or an unrecognised sorts[].field on /reports/list.

404

testId or activationId names a campaign or run the caller cannot see, or an activationId that does not belong to the named testId. Never 403, for the reason LIST vs. ALL Filtering gives for every other endpoint.

503 (9503)

The result store cannot be read — unreachable or schema-behind, told apart by details.reason — see Rules Shared by All Four Endpoints.

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 X-FT-Domain-Scope: ALL, reads included. That is stricter than any other endpoint of this service, including the two administration endpoints of M3, where only the writes demand scope ALL (The Two Administration Tables Are Not Authorized the Same Way).

Three independent reasons, each sufficient on its own:

  1. The import creates anchor campaigns in arbitrary domains. An imported campaign takes the domain of the legacy update_group, never the caller’s, and the measurements go into an installation-wide ClickHouse table. A domain-scoped caller would be authorizing writes far outside their own scope.

  2. A partial import by a domain user would be permanent. The cursor and the watermark know nothing about domains, so a pass narrowed to one domain would still advance them past every other domain’s rows — and those rows could then never be imported, because on a MergeTree there is no way to fill a hole in the middle.

  3. The progress counters disclose the data volume of every other domain of the installation, which is exactly what domain scope exists to withhold.

This product has no role model at all (No Authentication by Design), so scope ALL is the closest thing to "installation administrator" the contract can express — the same reasoning FccModelCapabilityAdminService applies to installation-global configuration.

A missing or non-ALL scope is 403 with FT code 9403, and the message states why. This is not the 9401 of a malformed identity header pair — see [two-403-codes].

POST /FCC/admin/legacy-import/start

Request (FccLegacyImportStartRequest), every field optional:

Field Type Notes

kinds

List<SPEED|LATENCY>

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.

dryRun

Boolean

Measures the remaining work, records it as totalRowEstimate, and returns without importing anything. It claims nothing, writes neither ClickHouse nor a cursor, and is not behind the result-store gate — so it is always safe to call, including while an import is already running and including during a ClickHouse outage.

batchSize

Integer

Overrides ft.fcc.legacy-import.batch-size for this run only, bounded internally at 100000. It is the unit of the two-phase commit — one ftacs read, one ClickHouse insert, one cursor commit — so a smaller value means more frequent commits and a smaller window to re-check after a crash, at the cost of throughput. A null or non-positive value falls back to the configured default. See ft.fcc.legacy-import.*.

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

enabled

Whether ft.fcc.legacy-import.enabled allows a start at all. Status keeps answering when it does not, so an installation that has finished its migration can still read what was imported.

configuredZoneId

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 (kinds[].zoneId), because the day-windows it has already created were computed with it.

state, runId, heartbeatAt, startedAt, finishedAt, lastError

IDLE | RUNNING | STOPPING | COMPLETED | FAILED. COMPLETED is not terminal — see fcc_legacy_import.

cursorUgId, cursorSerial, cursorDirection, cursorStartedAt, cursorResultId, cursorIteration

One position, five fields plus the rank inside it. The traversal order is (ugId, serial, direction, startedAt-to-the-second, resultId), which is deliberately not id order — it is the order the iteration rank is defined over — so cursorResultId on its own says nothing about how far the import has got. cursorStartedAt is a local wall clock, because that is what the legacy column stores; read it in zoneId. cursorDirection carries the raw legacy type as text and never a direction name: type 0, 1, 3 and null all map to DOWNLOAD, so a name could not be turned back into the number the legacy table is compared on. Null for latency.

watermarkStartedBefore

The different question of what is complete: every row whose start_date is below it has been imported, and the next pass covers from there to its own cutoff. A local wall clock, like cursorStartedAt. Written only when a pass finishes. Null means no pass has ever completed and the next one has no lower bound.

watermarkResultId

The highest legacy id a completed pass has covered. A diagnostic high-water mark that bounds no read — completeness is watermarkStartedBefore — and half of the rowsLateSkipped predicate.

pendingFromId, pendingResultId, pendingUgId

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 pendingUgId names and for no other.

totalRowEstimate

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.

rowsRead, rowsWritten, rowsSkipped

rowsSkipped is rows the crash recovery found already present in ClickHouse.

rowsLateSkipped

Measurements that reached ftacs after their day-window had already been imported. They are deliberately not back-filled: the window is closed and already ranked, and inserting into it would give two measurements the same iteration position in a store from which nothing can be deleted. A known limitation made visible — see Late Arrivals Are Counted.

rowsInvalid

Rows that cannot be expressed as a cursor position at all — a null serial, start_date or speed type. One of them stops the pass where it stands, with the cursor unmoved and the watermark unadvanced, so nothing is ever declared imported that was not; lastError names the campaign and the row. See A Null Cursor Component Halts the Pass.

bytesOverflowCount, tierParseFailed, statusUnknownCount, propertiesMissingCount

Data-quality tallies. Every one of those rows was imported anyway — see Legacy FCC Import for what each degradation means.

anchorsCreated, activationsCreated, devicesSnapshotted, devicesUnresolved

What this run created in ftfcc. devicesUnresolved counts serials with no live ftacs.cpe row: their measurements are imported and appear in every unfiltered report, and are unreachable only by the model and manufacturer filter, which resolves through the fleet.

stopRequested

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 (9400)

ft.fcc.legacy-import.enabled=false on /start, or ft.fcc.legacy-import.zone naming a zone id Java does not know — an unparseable zone is refused rather than silently defaulted, because the legacy timestamps carry no offset and a wrong zone displaces every imported measurement by a fixed amount. Also the ordinary body-validation 400, including an unknown constant in kinds.

403 (9403)

X-FT-Domain-Scope is not ALL, on any of the three — see [legacy-import-requires-all-scope]. (A missing or malformed identity header pair is 9401 from the filter, before the controller is reached.)

409 (9409)

/start only: the kind is already being imported by a run whose heartbeat is still fresh, possibly on another service instance — the claim is a conditional UPDATE on fcc_legacy_import, not a process-local lock. A 409 and not a 423: the request is perfectly valid and will succeed as soon as the other run ends. The message names the holding runId and its last heartbeat. See What a 409 Means.

503 (9503)

/start only, and not for dryRun: the same FccResultStorageGate every activation and report goes through. Beginning an import during an outage would read millions of rows, fail every insert, and leave a cursor that has moved past rows nothing wrote. A store that goes down mid-run is not an error response at all — the pass ends cleanly at IDLE on its last committed cursor. See What a 9503 Means.

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. <fccTests> holds <test> elements, plural, and PUT /FCC/item/import reads every one of them in a loop — so a file assembled from several exports, or produced by an older release, still imports in one call. The format describes what a document may contain; this endpoint decides what it writes.

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 (9400)

The id names a legacy anchor (source=LEGACY or state=ARCHIVED) — it carries no configuration to export — or the request body fails bean validation. Refused before the first byte; see Legacy Anchors Are Refused in Neither Direction.

404 (9404)

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.

  • id is null, because nothing exists yet; that null is precisely what the save reads as "create".

  • state is NOT_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.type is Immediately. The document deliberately carries no schedule (What the Format Does Not Carry), and answering the parsed null would leave the form’s own radio unset on a screen about to be saved.

  • configs[0].sourceType is Individual and 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; Individual is what makes the save read selectedDevices.

  • selectedDevices are cpe ids, resolved from the document’s serials through the save path’s own resolveForSave, 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 (9400)

Everything the document can get wrong, at any point in the parse — a file name not ending in .xml, an empty upload, an unreadable one, a wrong root element, a missing/blank/non-numeric/unsupported formatVersion, no <test> at all, more than one <test>, a missing <type>, a settings block that does not match the type, an unknown or repeated element, an element the format deliberately omits, a fleet over ft.fcc.max-devices-per-test, and a serial that matches no device visible to the caller. None of them can leave anything behind, because nothing is written: see The Import Parses and Returns.

413 (9413) / 415 (9415)

The multipart upload itself, before the document is read at all — see above.

500

IllegalStateException from FccConfigRecordFields — a programming error, not a caller’s mistake: a record component of a type the reflective converter cannot carry. It propagates, so an operator sees a 500 rather than a message claiming their document was wrong for a reason that is not theirs to fix. See what the parse judges.

What this endpoint used to be, and what went with it. It created a campaign per <test>, each in its own transaction, and answered 200 with FccImportResponse: formatVersion, total, imported, failed and a results[] of per-test outcomes. That shape existed because the writes were irreversible — a failure mid-document left the earlier campaigns created, so every such refusal had to state how many.

FccImportResponse, FccImportResultItem and FccImportError are gone, with the per-test transaction, the counters, the domain-entitlement rule at import time and ft.fcc.config-import.max-tests. 404 left the operation too, with the id lookup it used to do. What a document still cannot do is be half-applied — now because nothing is applied at all.

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.

Reserved, Not Used in v1

Endpoint Purpose Status

POST /FCC/internal/platform-event

Reserved; not used in v1 — the ACS has no instance-level webhook, and an ACS restart is instead surfaced through ACTIVATION_FAILED(ACS_RESTART) and the watchdog.

Reserved