Summary
GTS Type Schemas can be generated two different ways today, and the two disagree:
- Runtime macro —
#[struct_to_gts_schema(...)] expands into GtsSchema trait impls; calling Type::gts_schema_with_refs() (e.g. via gts-macros-cli --dump) emits the schema with full type information available.
- Static generator —
gts generate-from-rust (gts-cli/src/gen_schemas.rs) scans .rs source files with regular expressions, extracts the macro attribute body + struct fields textually, and builds the JSON directly, without compiling the code.
The static generator produces structurally incorrect schemas for generic-nested derivation chains, because reconstructing the nesting requires Rust type resolution that a regex pass cannot do. We should consolidate on the runtime macro as the single source of truth and retire the regex-based generator.
The defect
For a generic-base chain such as:
#[struct_to_gts_schema(base = true, type_id = "gts.x.core.events.type.v1~", ...)]
pub struct BaseEventV1<P> { /* ... */ pub payload: P }
#[struct_to_gts_schema(base = BaseEventV1, type_id = "gts.x.core.events.type.v1~x.core.audit.event.v1~", ...)]
pub struct AuditPayloadV1<D> { pub user_agent: String, pub user_id: Uuid, pub ip_address: String, pub data: D }
an instance carries AuditPayloadV1's fields under payload (payload.user_agent, …), so the derived schema's allOf overlay must nest its properties under the parent chain's generic-slot path.
Runtime macro (correct) — overlay nested under payload:
{
"$id": "gts://gts.x.core.events.type.v1~x.core.audit.event.v1~",
"allOf": [
{ "$ref": "gts://gts.x.core.events.type.v1~" },
{ "type": "object", "properties": {
"payload": { "type": "object", "additionalProperties": false,
"properties": { "user_agent": {...}, "user_id": {...}, "ip_address": {...}, "data": {"type":"object"} },
"required": ["user_agent","user_id","ip_address","data"] } } }
]
}
Static generator (wrong) — overlay flat at the top level:
{
"$id": "gts://gts.x.core.events.type.v1~x.core.audit.event.v1~",
"allOf": [
{ "$ref": "gts://gts.x.core.events.type.v1~" },
{ "type": "object",
"properties": { "user_agent": {...}, "user_id": {...}, "ip_address": {...}, "data": {...} },
"required": ["user_agent","user_id","ip_address","data"] }
]
}
The flat schema fails to validate real instances (the fields live under payload, not at the root) and silently disagrees with the runtime macro for the same type.
Root cause
The generic-slot field names (payload, data, last, …) live in the type system. The runtime macro reads them through trait associated consts — <Parent<()> as GtsSchema>::GENERIC_FIELD and Parent::outer_generic_path() (see gts/src/schema.rs) — which the compiler resolves correctly for arbitrary chains, cross-file, through renames and re-exports.
The static generator (gts-cli/src/gen_schemas.rs) has no type system. To match the runtime output it would have to, by regex:
- resolve
base = AuditPayloadV1 to the right struct (possibly in another file),
- discover that struct is generic (
<D>) and which field holds the generic param (data: D),
- recurse up the whole
base = … chain collecting each ancestor's generic field,
- rebuild the
["payload","data", …] path and wrap the overlay accordingly.
That is a partial re-implementation of Rust name resolution. It is fragile against Option<P> / Vec<P> / Box<P>, multiple generic params, where clauses, lifetimes, same-named structs across modules, and aliases — and it becomes a second source of truth that will drift from the trait impls.
Impact
gts generate-from-rust (run via make generate-schemas) emits incorrect schemas for any generic-nested chain and overwrites correct runtime-generated fixtures (e.g. gts-macros-cli/src/schemas/) with flat/wrong ones.
- It also scatters extra static-generated fixtures into
gts-macros/tests/schemas/ (these are currently untracked output artifacts, not test inputs — tests only reference file-path constants, not the JSON content — so they are harmless today, but they are noise and reinforce the wrong output).
Proposal: consolidate on the runtime macro, retire the regex generator
The regex generator's only apparent advantage — "generate without compiling" — is illusory: an annotated struct must compile for the macro to expand at all. That nominal benefit is paid for with incorrectness. The runtime macro is the source of truth and is correct for every derivation form. We should make it the only generation path and remove gts generate-from-rust (or reduce it to a thin wrapper over the runtime path).
This is a redesign, not a deletion. Two prerequisites:
- Auto-registration of annotated types. Today the runtime dump (
gts-macros-cli/src/main.rs) enumerates each type by hand (gts_schema_for!(BaseEventV1<()>), …). To replace "scan a source tree", every annotated type should self-register (e.g. via the inventory crate, as module discovery already does elsewhere) so a single --dump emits all schemas of the linked crate without manual enumeration.
- A consumer-facing generation API.
generate-from-rust --source <dir> nominally runs over an arbitrary tree. The runtime approach requires the annotated structs to be linked into a binary that calls their trait methods (Rust has no runtime reflection). Consumers therefore need a provided pattern — a small generator binary/test that calls a dump_all_schemas() helper, or a build.rs codegen step — instead of pointing a regex tool at source files.
Interim mitigation (separate, smaller)
Independently of this consolidation, the emitter and OP#12 were adjusted so derived schemas no longer carry a redundant root additionalProperties: false (a draft-07 footgun); see docs/bugs/op12-derived-additional-properties.md. As a stopgap until the generator is consolidated, make generate-schemas should exclude gts-macros-cli from the static scan and generate that crate's demo schemas via the runtime binary (gts-macros-cli --dump), so the correct fixtures are not clobbered.
Acceptance criteria
- A single, type-system-backed path generates all GTS Type Schemas; generic-nested chains come out identical to
gts_schema_with_refs().
- Annotated types are discovered automatically (no manual enumeration in the dumper).
- A documented pattern exists for consumer crates to generate their own schemas.
- The regex-based
gts generate-from-rust is removed or reduced to a wrapper over the runtime path.
make generate-schemas is idempotent and never produces schemas that disagree with the runtime macro.
Summary
GTS Type Schemas can be generated two different ways today, and the two disagree:
#[struct_to_gts_schema(...)]expands intoGtsSchematrait impls; callingType::gts_schema_with_refs()(e.g. viagts-macros-cli --dump) emits the schema with full type information available.gts generate-from-rust(gts-cli/src/gen_schemas.rs) scans.rssource files with regular expressions, extracts the macro attribute body + struct fields textually, and builds the JSON directly, without compiling the code.The static generator produces structurally incorrect schemas for generic-nested derivation chains, because reconstructing the nesting requires Rust type resolution that a regex pass cannot do. We should consolidate on the runtime macro as the single source of truth and retire the regex-based generator.
The defect
For a generic-base chain such as:
an instance carries
AuditPayloadV1's fields underpayload(payload.user_agent, …), so the derived schema'sallOfoverlay must nest its properties under the parent chain's generic-slot path.Runtime macro (correct) — overlay nested under
payload:{ "$id": "gts://gts.x.core.events.type.v1~x.core.audit.event.v1~", "allOf": [ { "$ref": "gts://gts.x.core.events.type.v1~" }, { "type": "object", "properties": { "payload": { "type": "object", "additionalProperties": false, "properties": { "user_agent": {...}, "user_id": {...}, "ip_address": {...}, "data": {"type":"object"} }, "required": ["user_agent","user_id","ip_address","data"] } } } ] }Static generator (wrong) — overlay flat at the top level:
{ "$id": "gts://gts.x.core.events.type.v1~x.core.audit.event.v1~", "allOf": [ { "$ref": "gts://gts.x.core.events.type.v1~" }, { "type": "object", "properties": { "user_agent": {...}, "user_id": {...}, "ip_address": {...}, "data": {...} }, "required": ["user_agent","user_id","ip_address","data"] } ] }The flat schema fails to validate real instances (the fields live under
payload, not at the root) and silently disagrees with the runtime macro for the same type.Root cause
The generic-slot field names (
payload,data,last, …) live in the type system. The runtime macro reads them through trait associated consts —<Parent<()> as GtsSchema>::GENERIC_FIELDandParent::outer_generic_path()(seegts/src/schema.rs) — which the compiler resolves correctly for arbitrary chains, cross-file, through renames and re-exports.The static generator (
gts-cli/src/gen_schemas.rs) has no type system. To match the runtime output it would have to, by regex:base = AuditPayloadV1to the right struct (possibly in another file),<D>) and which field holds the generic param (data: D),base = …chain collecting each ancestor's generic field,["payload","data", …]path and wrap the overlay accordingly.That is a partial re-implementation of Rust name resolution. It is fragile against
Option<P>/Vec<P>/Box<P>, multiple generic params,whereclauses, lifetimes, same-named structs across modules, and aliases — and it becomes a second source of truth that will drift from the trait impls.Impact
gts generate-from-rust(run viamake generate-schemas) emits incorrect schemas for any generic-nested chain and overwrites correct runtime-generated fixtures (e.g.gts-macros-cli/src/schemas/) with flat/wrong ones.gts-macros/tests/schemas/(these are currently untracked output artifacts, not test inputs — tests only reference file-path constants, not the JSON content — so they are harmless today, but they are noise and reinforce the wrong output).Proposal: consolidate on the runtime macro, retire the regex generator
The regex generator's only apparent advantage — "generate without compiling" — is illusory: an annotated struct must compile for the macro to expand at all. That nominal benefit is paid for with incorrectness. The runtime macro is the source of truth and is correct for every derivation form. We should make it the only generation path and remove
gts generate-from-rust(or reduce it to a thin wrapper over the runtime path).This is a redesign, not a deletion. Two prerequisites:
gts-macros-cli/src/main.rs) enumerates each type by hand (gts_schema_for!(BaseEventV1<()>), …). To replace "scan a source tree", every annotated type should self-register (e.g. via theinventorycrate, as module discovery already does elsewhere) so a single--dumpemits all schemas of the linked crate without manual enumeration.generate-from-rust --source <dir>nominally runs over an arbitrary tree. The runtime approach requires the annotated structs to be linked into a binary that calls their trait methods (Rust has no runtime reflection). Consumers therefore need a provided pattern — a small generator binary/test that calls adump_all_schemas()helper, or abuild.rscodegen step — instead of pointing a regex tool at source files.Interim mitigation (separate, smaller)
Independently of this consolidation, the emitter and OP#12 were adjusted so derived schemas no longer carry a redundant root
additionalProperties: false(a draft-07 footgun); seedocs/bugs/op12-derived-additional-properties.md. As a stopgap until the generator is consolidated,make generate-schemasshould excludegts-macros-clifrom the static scan and generate that crate's demo schemas via the runtime binary (gts-macros-cli --dump), so the correct fixtures are not clobbered.Acceptance criteria
gts_schema_with_refs().gts generate-from-rustis removed or reduced to a wrapper over the runtime path.make generate-schemasis idempotent and never produces schemas that disagree with the runtime macro.