Inside the synapse Coder: The Whitelist, the Ledger, and the Edge Cases
The entire CoreModels `synapse` profile can be stated in one sentence: *encode the neutral model as JSON Schema, then keep only what the Synapse `JsonSchema` REST object has a field for — and write down every single thing that rule removes.* This deep dive unpacks that sentence into the parts an integrator eventually needs: the IR-to-output mapping, the exact keyword whitelist and how strips are classified, the extras channel that carries verbatim keywords through the neutral model, the complete inventory of lossiness records the encoder can emit, and the edge cases our test suite pins — including the ones where we deliberately refuse to guess.
Inside the synapse Coder: The Whitelist, the Ledger, and the Edge Cases
The entire CoreModels synapse profile can be stated in one sentence: encode the neutral model as JSON Schema, then keep only what the Synapse JsonSchema REST object has a field for — and write down every single thing that rule removes. This deep dive unpacks that sentence into the parts an integrator eventually needs: the IR-to-output mapping, the exact keyword whitelist and how strips are classified, the extras channel that carries verbatim keywords through the neutral model, the complete inventory of lossiness records the encoder can emit, and the edge cases our test suite pins — including the ones where we deliberately refuse to guess.
Grounding, stated up front: the profile is built against the public Synapse REST documentation (the JsonSchema object's field set is the authoritative subset) and live publicly registered schemas — sage.annotations-experimentalData.specimenID-0.0.1 and SynapseDocs-Color, the latter captured verbatim in mid-2026 and used as a shape fixture in our tests. Where the public record does not settle a behavior, the coder records the uncertainty instead of inventing an answer. And the direction is singular by design: synapse encodes only; its output is plain draft-07, decoded — when you need the reverse — by the jsonschema format.
The pipeline in one paragraph
The Synapse encode is a down-conversion of our standard JSON Schema export: the neutral model (IR) goes through the same encoder that produces the jsonschema output, bracketed by ordered passes that apply the Synapse conventions — date-time preparation before the delegated encode, then IR-level lossiness recording, the $defs → definitions rename, the recursive whitelist scrub, and the identity pass that stamps $schema and the registered $id first, the keyword order the public fixtures use.
IR construct → Synapse output
| IR construct | Synapse output | Notes |
|---|---|---|
| Root type | The root object schema (type, properties, required, title) | title from the root type's label when the source carried none |
| Non-root type | An entry under definitions | Never $defs — the rename rewrites the container and every $ref pointer |
| Type with a parent | allOf: $ref to the parent in definitions + inline member with own properties | Verified by test: #/definitions/CreativeWork, no $defs anywhere |
| Element | A property under properties; Required collects into the required array | Labels are the property names |
| String / RichText primitive | "type": "string" | RichText's approximation is recorded by the underlying JSON Schema layer |
| Integer / Double / Boolean | "type": "integer" / "number" / "boolean" | |
| DateTime (single-valued) | "type": "string" with "format": "date-time" | Plus a TypeApproximation record — see the inventory below |
| DateTime (collection) | Array of plain strings, no format | A per-item format cannot be placed; recorded, not faked |
| Taxonomy reference | enum of term ids, with "type": "string" added when all members are strings | The type+enum pairing matches the public SynapseDocs-Color schema |
| Taxonomy term hierarchy | Flattened — a draft-07 enum is a flat list | The hierarchy survives only in the curation manifest's value-set Parent column |
| Collection cardinality | "type": "array" with items, minItems, maxItems | All three are fields of the Synapse object |
| Type reference | "$ref": "#/definitions/{TypeId}" | |
Nullable | Not represented | Recorded as SemanticNarrowing; see "what we refuse to guess" |
| Components | Nothing | One StructuralDrop naming the count |
| Relations | Nothing | One StructuralDrop naming the count |
The whitelist, exactly
A keyword survives the scrub if and only if it is a field of the Synapse JsonSchema REST object. The full supported set:
$schema $id $ref type items properties title description
allOf anyOf oneOf not format definitions enum const source
required maxLength minLength maxItems minItems uniqueItems pattern
if then else maximum minimum default contains additionalProperties
Everything else is removed — and how the removal is reported depends on what the keyword did. Keywords that carried a validation rule are classified as ConstraintRelaxation, because their removal loosens what the schema enforces:
multipleOf exclusiveMinimum exclusiveMaximum maxProperties minProperties
patternProperties dependencies dependentRequired dependentSchemas
propertyNames additionalItems prefixItems unevaluatedProperties
unevaluatedItems minContains maxContains
Any other stripped keyword — annotations like $comment, examples, readOnly, writeOnly, contentMediaType, vendor extensions, unknowns — is a StructuralDrop. One record per strip, each carrying the exact JSON path (#/properties/vialCount/multipleOf), which makes CI allowlists of acknowledged strips practical. A dedicated test walks every key of the encoded output and asserts no unsupported keyword survives anywhere.
The scrub recurses through every position a subschema can occupy: single-subschema keywords (items, not, if, then, else, contains, additionalProperties), subschema arrays (allOf, anyOf, oneOf), and name→subschema maps (properties, definitions).
The extras namespace: how foreign keywords travel
The neutral model carries an annotations bag on every node with an open Extras map — the channel through which keywords the IR does not model structurally travel verbatim. When our jsonschema decoder reads a source, structurally consumed keywords (type, properties, required, items, enum, $ref, allOf, minItems, maxItems, $defs, definitions) are rebuilt on encode; everything else — pattern, minimum, $schema, title, a stray x-vendor-thing — is preserved in Extras and re-emitted as-is. The synapse scrub then runs over the re-emitted result, which yields a clean division of labor: preservation is the neutral layer's job, subsetting is the Synapse layer's job, and the ledger sits between them.
Three consequences worth knowing:
- The SIA annotation vocabulary is stripped here. Our own semantic keywords (
x-sia-role,x-sia-priority,x-sia-instruction,x-maps-to) appear in the standardjsonschemaexport but are not fields of the Synapse object, so they strip like anything else — each with aStructuralDroprecord. A test asserts exactly this forx-sia-role. - Coder-namespaced extras never leak. Extras keyed with a dotted namespace by other format coders are excluded from JSON Schema emission entirely, so they never even reach the scrub.
- Combinator payloads are preserved verbatim — and scrubbed recursively.
anyOf/oneOf/if/then/else/notbodies the IR cannot model structurally travel throughExtrasas raw JSON, and the scrub still descends into them — including the strangest position draft-07 allows, tuple-formitems.
That last case is worth seeing concretely, because it is the one our tests guard hardest. A property carrying this preserved payload:
{
"anyOf": [
{
"type": "array",
"items": [
{ "type": "string", "multipleOf": 3, "$ref": "#/$defs/X" }
]
}
]
}
encodes as:
{
"anyOf": [
{
"type": "array",
"items": [
{ "type": "string", "$ref": "#/definitions/X" }
]
}
]
}
with one ConstraintRelaxation at #/properties/poly/anyOf/0/items/0/multipleOf. Each member of a tuple-form items array is a schema position: the walk enters it, strips with correctly indexed paths, and rewrites nested refs. Without that pass, a verbatim payload would be a tunnel under the whitelist.
Identity: the $id grammar and its guards
The registered-schema $id is {base}{org}-{name}-{semver}, where the base is Synapse's production registered-type URI. Because the hyphen delimits the three segments, it cannot appear inside one — so segment inputs are sanitized: ASCII letters, digits, and dots survive; anything else folds to a camelCase boundary (my org-x → myOrgX, blog-post → blogPost); leading/trailing dots are trimmed; a fully consumed input falls back to schema. Dots are legal because real organizations and names use them (sage.annotations, experimentalData.specimenID).
The version segment gets its own guard: only plain major.minor.patch is accepted. 1.0.0-rc1 or 0.0.1 beta would corrupt the URI's segment structure, so the encoder substitutes the default 0.0.1 and records a SemanticNarrowing at #/$id naming the rejected version — the call succeeds, the fallback is documented, and a CI gate on that path turns it into a build failure. An empty version is meaningful, not invalid: it emits the unversioned registered-pointer form, the shape the public SynapseDocs-Color schema uses.
Two more identity rewrites, both recorded: a source dialect other than draft-07 is down-converted (SemanticNarrowing at #/$schema), and a source $id that is not already a registered-schema URI is replaced (StructuralDrop at #/$id).
The complete lossiness inventory
Every record this encoder can emit, by trigger:
| Trigger | Kind | Path shape |
|---|---|---|
Single DateTime emitted as string + format: date-time (enforcement unconfirmed) | TypeApproximation | Element[id] |
| DateTime collection emitted as plain string array | TypeApproximation | Element[id] |
Nullable element (no null-union invented) | SemanticNarrowing | Element[id] |
| Taxonomy with parented terms flattened to a flat enum | StructuralDrop | Taxonomy[id] |
| Component projections (no representation) | StructuralDrop | schema.components |
| Relations (no representation) | StructuralDrop | schema.relations |
| Stripped constraint keyword | ConstraintRelaxation | #/…/keyword |
| Stripped annotation/unknown keyword | StructuralDrop | #/…/keyword |
type array narrowed to its single non-null member | SemanticNarrowing | #/…/type |
type array with multiple non-null members dropped | ConstraintRelaxation | #/…/type |
| Non-draft-07 dialect down-converted | SemanticNarrowing | #/$schema |
Source $id replaced by the registered URI | StructuralDrop | #/$id |
| Non-semver version option replaced by the default | SemanticNarrowing | #/$id |
The type-array rows deserve a note: Synapse documents type as a single value, never an array, so ["string", "null"] (arriving via a preserved combinator payload) narrows to "string" with a record — while ["string", "integer"] has no single member that preserves the union, so the keyword is dropped and the loss classified as a relaxation.
What we refuse to guess
Four behaviors are handled conservatively pending real platform fixtures rather than encoded on speculation: whether Synapse accepts an anyOf null-union for nullable fields (Nullable is narrowed with a lossiness record, and explicit nulls will not validate — our test asserts no anyOf is invented); whether format is enforced as an assertion or treated as a draft-07 annotation (every date-time emit carries a record saying so); cross-schema $ref resolution (the structural encode produces only internal #/definitions/... references); and non-string enum handling (the type: "string" companion is added only when every member is a string, matching the one public fixture we can verify). An honest subset encoder must be honest about the boundary of its own knowledge, too.
Round-trip edge cases
Calling decode on the synapse format fails fast, with the error pointing at jsonschema — asserted by test, mirrored by the API and MCP dispatch. The output being plain draft-07 means the round trip is real, just asymmetric: re-decode via jsonschema and the surviving keywords ride the extras channel back into the IR intact. What is gone is gone — the strips are recoverable only from the ledger you kept. And by construction a second encode of a re-decoded output is quiet: the dialect already matches draft-07, the $id already carries the registered base, the enums already carry their type — the strip list has nothing left to say. Convergence on a fixed point, with the journey fully accounted for in the first run's ledger: that is the profile's contract.