JSON serialization contract (Newtonsoft → System.Text.Json parity)¶
The API's JSON wire (MVC responses and request bodies, and the NotificationHub protocol) is written by
Newtonsoft.Json today. The System.Text.Json migration plan
moves it to System.Text.Json (STJ) behind startup switches. This page is the contract that migration is measured
against: what today's bytes are, how they are pinned, which differences are known and which are accepted.
Status (R0, #4145): the parity harness (PR-0.1) and 13 of the 14
STJ attribute twins (PR-0.2) exist. Nothing serialises differently: R0 adds tests, golden files and attributes that
only STJ reads, and the STJ paths that already exist (swagger.json generation, MassTransit messages, the
definition-metadata serialisers) produce byte-identical output. No STJ serializer is wired to the API.
Where it lives¶
Everything is in src/services/api/SyRF.API.Endpoint.Tests/Serialization/ unless noted.
| Piece | File | Plan criterion |
|---|---|---|
| Today's wire, reproduced | NewtonsoftWire.cs |
§4 step 4 |
| Wire type enumeration (ApiExplorer + hub) | WireTypeCatalog.cs |
§4 step 1 |
| Deterministic instances | DeterministicInstanceBuilder.cs |
§4 step 3 |
| Newtonsoft goldens | WireGoldens.cs, NewtonsoftGoldenTests.cs, Goldens/rest/, Goldens/hub/ |
0.1.1, C1 |
swagger.json cross-check |
SwaggerSchemaCrossCheckTests.cs |
0.1.2 |
| Contract scanner and allowlist | ContractScanner.cs, ContractScannerTests.cs, contract-allowlist.json |
0.1.3, C2 |
ProblemDetails with a status extension |
ProblemDetailsGoldenTests.cs, Goldens/problem/ |
0.1.4 |
| Submission-digest pins (post-binding) | SubmissionDigestPostBindingPinTests.cs, Fixtures/session-submissions.json |
0.1.5 |
| Submission-digest pins (domain, screening) | SyRF.ProjectManagement.Core.Tests/ProjectStatistics/ProjectStatisticsSubmissionDigestPinTests.cs |
0.1.5 |
| Definition-metadata goldens | DefinitionMetadataGoldenTests.cs, Goldens/definition/ |
0.1.6 |
JsonConvert.DefaultSettings guard |
NewtonsoftDefaultSettingsGuardTests.cs; runtime asserts in ProjectStatisticsHostRegistrationTests (PM) and QuartzServiceIntegrationTests |
0.1.7 |
| MassTransit contract goldens | MassTransitContractGoldenTests.cs, Goldens/masstransit/ |
0.2.2 |
| Promotion safety (no existing STJ path reaches a twin) | StjPromotionSafetyTests.cs (API and SyRF.PdfAgent.Tests), walker src/testing/StjTwinReachability.cs |
R0 |
Run the harness with:
nice dotnet test src/services/api/SyRF.API.Endpoint.Tests --filter "FullyQualifiedName~SyRF.API.Tests.Serialization"
nice dotnet test src/libs/project-management/SyRF.ProjectManagement.Core.Tests --filter "FullyQualifiedName~ProjectStatisticsSubmissionDigest"
Goldens¶
- What is enumerated. ApiExplorer over the real controller set, with NSwag's registration so it sees the same
215 operations
swagger.jsondescribes. Every response type and[FromBody]type is normalised (task,ActionResult<T>, nullable and collection wrappers removed; dictionaries kept) to one REST golden per type. AJsonPatchDocument<T>body is goldened as itself, although ApiExplorer reports it asOperation[]. The hub side is one golden perINotificationHubClientmessage and per publicNotificationHubmethod (invocation frame, plus the completion frame when the method returns a value). - How bytes are produced. REST bytes come from executing an
ObjectResultagainst a container built like the API's (AddControllers().AddNewtonsoftJson()plusConfigureJsonOptions), so the framework's ProblemDetails converters and formatter selection apply. Hub frames come fromNewtonsoftJsonHubProtocolwith a copy ofProgram.cs'sAddNewtonsoftJsonProtocolsettings. Two tests keep the copies honest: one compares theProgram.csblock text, and one builds the real API host and compares its resolved MVC and hub settings with the harness's. - Instances. Members are exactly what Newtonsoft's contract resolver serialises, filled from a counter. Three
variants per type:
populated(UTC dates, defined enums, whole doubles, decimals with a trailing zero),edge(unspecified-kind dates, undefined enum integers, fractional doubles, escaping-sensitive text) andnulls(every member the code declares nullable set to null). Collections of a polymorphic element hold one instance of each concrete subtype; single polymorphic members rotate through the subtypes; polymorphic roots get one variant per subtype. Dictionaries use the keysABCKey,Species name,1aandcamelCase. - Format. Each file is JSON: a
subjectand abytesobject whose values are today's exact output, one variant per line, so a diff shows which variant moved. - Changing them. A golden diff is a wire change. Regenerate deliberately with
SYRF_UPDATE_JSON_GOLDENS=1(it also rewrites the allowlist's finding details and deletes stale goldens) and explain the diff in the PR. A new endpoint or DTO needs its golden committed.
Contract scanner¶
ContractScanner walks the closure of every wire root and reports behaviours that differ between the two
serializers unless the type opts in. contract-allowlist.json must list exactly the current findings, each with
disposition: fix (and by: the plan PR that fixes it) or disposition: intentional (and a reason).
| Category | Finding | Why it matters |
|---|---|---|
| B1 | A member typed as an abstract class or interface (or a collection or dictionary of one) | Newtonsoft writes the runtime type; STJ writes the declared type and drops subtype members |
| B6 | A request type with no constructor STJ can use | Newtonsoft falls back to a non-public parameterless constructor; STJ throws |
| B7 | A request type's collection with no public setter or constructor parameter | Newtonsoft populates it in place; STJ leaves it empty |
| B8 | A serialised public field, or [JsonProperty] on a non-public member |
STJ ignores both without [JsonInclude] |
| J4 | A Newtonsoft attribute in any SyRF assembly without its STJ twin | The member's behaviour would change on the switch |
| OBJ | An object/JToken member or a non-string dictionary key |
Reads bind JsonElement instead of JObject; key conversion differs |
B6 and B7 are checked only for the closure of request bodies and hub arguments. J4 and the non-public
[JsonProperty] half of B8 scan every type in the SyRF assemblies the API loads, not only the wire closure.
The scan of main before R0 reported the 14 Newtonsoft-only attributes of plan §1.3 (J4) and the private
StageSecuritySettings._customStagePermissions field (B8). PR-0.2 twinned 13 of them; the one J4 finding left is
below. It also found more B1 members than the plan's grep: the ProjectDbBaseDto members of
five project responses and AnnotationSessionDetailDto.Annotations, besides QuestionOptionDto.ParentFilter,
TargetDto.ConditionalParentAnswers, SessionSubmissionWebDto.Annotations and NextStudyResponseDto.NextStudy.
StatsWithIncompleteDto is not on the wire.
STJ attribute twins (house dual pattern)¶
Withheld: ProjectAgreementThreshold.AgreementMode. MassTransit already serialises with STJ, and the MassTransit
golden shows IUpdateStudyScreeningStatsCommand carrying a ProjectAgreementThreshold with the computed
agreementMode in its JSON. The STJ [JsonIgnore] twin would remove that field from the message. No consumer reads
it (STJ uses the public parameterless constructor and the property has no setter), but plan 0.2.2 requires the
backend-plan owner's agreement for any contract diff, and §12 sequences this type after backend plan PR-11 (C15,
the agreement threshold lost in MassTransit deserialisation). The twin is a one-line follow-up once PR-11 lands or
the owner agrees; the allowlist records it as fix by that follow-up.
Until R4 removes Newtonsoft, every Newtonsoft serialisation attribute carries its STJ twin beside it, never instead of it:
| Newtonsoft | STJ twin |
|---|---|
[JsonIgnore] |
[System.Text.Json.Serialization.JsonIgnore] |
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)] |
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] |
[JsonProperty] on a non-public member |
[JsonInclude] |
[JsonProperty("name")] |
[JsonPropertyName("name")] |
[JsonConverter(typeof(StringEnumConverter))] |
[JsonConverter(typeof(JsonStringEnumConverter))] |
Promotion safety: why the twins change no output today¶
STJ honours the twins wherever production code already uses STJ, so "attribute-only" holds only if no such
path reaches a twinned type: an Entity/AggregateRoot subclass, StageSecuritySettings,
ProjectReviewerStats or AllocationRegimeOrigin. [JsonInclude] and JsonStringEnumConverter would add
output, which is why they matter most. A sweep of every production STJ call site in R0 found none that reaches
them:
- Automated.
StjTwinReachabilitywalks the closure STJ itself resolves. It honours[JsonIgnore]and[JsonInclude], and follows elements, keys and values.StjPromotionSafetyTestscovers: - every site whose type is nameable (API, PM.Core, SharedKernel, the s3-notifier's shared value objects, the PDF agent's journals, proofs, scratch markers and health snapshot);
- every MassTransit contract, which also covers the Quartz message scheduler and
DelegatedWorkGate. OnlyIUpdateStudyScreeningStatsCommandreaches a candidate: the withheldProjectAgreementThreshold. - By inspection. These sites carry no twinned type:
- Anonymous objects of primitives:
Program.cs:803, theIdentityService/AccountControllerrequest bodies,UserImpersonationMiddleware, the bundle-reader budgets, the s3-notifier log payloads. JsonElementreads inBffAuthControllerandAccountController.Dictionary<string, object>holding strings, numbers and booleans (the BFF PKCE cookie, the health response).- The Identity host and migration tool, which reference only SharedKernel and Identity.Shared and name no
Entity/AggregateRoottype. - Pinned by goldens.
swagger.jsongeneration (0.2.1), MassTransit contracts (0.2.2) and the checkpoint definition serialisers (J8, 0.1.6). - Not STJ. MVC and the hub (Newtonsoft today), the Quartz
JobDataMap(Newtonsoft) and the s3-notifier DynamoDB outbox (attribute values).
Persisted bytes the migration must not move by accident¶
- FEAT-024 submission digests (0.1.5, D6).
ProjectStatisticsSubmissionDigesthashes NewtonsoftJToken.FromObjectoutput and the digest is persisted. Absolute pins cover each screening decision with and without reconciliation, each of the eightAnnotationsubtypes,IsDirtywith pendingEvents, null notes, parent and answer, a decimal with a trailing zero, anOutcomeDatawith fractional time points andExtraElements, eachSessionStatusand an explicit entity order. The post-binding pins bind synthetic JSON through today's MVC settings and map it asReviewController.SubmitSessiondoes. A digest change is allowed only at a ship point: the PR re-commits the values and records that staging statistics are reset and production holds no stored digests (plan D6). - Decimal binding today. Through MVC's default
FloatParseHandling.Double,AnnotationConverterreads decimal answers through a double:1.50binds as1.5,0.30000000000000004as0.3and12345678901234567.89as12345678901234600.TodaysBindingReadsDecimalsThroughADoublerecords this for R1 (1.1.4). - Definition metadata (0.1.6).
StageStatisticsDefinitionSnapshot.Serialize,ReviewerAnnotationScopeCalculator.DescribeDefinition,QuestionAnswersScopeCalculator.DescribeDefinitionandReviewerProgressDefinition.Serializeuse STJ default options and feed stored checkpoint digests. They stay on default options; the allowlist'sdefaultOptionsSerializerssection records them and each has a golden. JsonConvert.DefaultSettings(0.1.7) stays unset. A source scan fails on any assignment undersrc; the real API host, the PM container and the Quartz host assert it is null at runtime.
Known differences and observations¶
| # | Observation | Status |
|---|---|---|
| B12 | The statistics admin 409 writes "status":409 and then the extension "status":"<lifecycle>", so JSON.parse keeps the extension |
Pinned byte-for-byte (0.1.4); keep it through the migration (D10) |
| — | Newtonsoft's ProblemDetailsConverter writes Extensions keys as given, not camelCased, while objects inside an extension are camelCased |
Pinned by rest/ProblemDetails.golden.json |
| B14 | Newtonsoft writes whole doubles as 1.0; STJ writes 1 |
Intentional difference (the comparator normalises numbers) |
| D5 | JSON Patch bodies stay on a Newtonsoft formatter until R4 | Allowlisted (Operation.value) |
Flag decision¶
R0 is not flagged: it changes no runtime behaviour. The goldens, scanner, pins and guards are tests, and the
STJ twins are read only by STJ, which serves no API traffic until the R1/R2 switches (stjMvcSerializer,
stjHubProtocol) exist.