Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .agents/skills/loom-framework/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ consumer validation.

## Architecture Boundaries

- Distinct HTTP media mappings belong to endpoint/response occurrences, not to
reusable service unions. Transport IR retains each branch's schema, examples,
codec and field encoding. Form roots have independent native-presence carriers;
nested JSON fields retain JSON carriers. Conversion contexts must honor both
true and false recorded presence policies. Incoming matching and outgoing
consistency share `internal/httpcodec`; select factories before response status.
Keep the media-selection and physical-layout models and generated exchange
specimen aligned.

- `admission.Rate` serializes the maintained token bucket's Allow and complete
rate/burst policy updates. Preserve accrued allowance, clamp on reductions and
never reset quota on reconfiguration. `RateAdmission` owns bounded cooperative
Expand Down Expand Up @@ -1225,6 +1234,17 @@ There is one DSL parser, one shared semantic IR, and one renderer.
the same projected values emitted in the schema. Ambiguous wire shapes
remain omitted. Never reinterpret typed bytes separately for a competing
string branch; `internal/ir/tla/byte_examples` records that counterexample.
- Schema example analysis retains the complete prepared authored group in order.
Carry plural values through component annotation snapshots, nullable wrappers,
async overlays, schema identity and rendering. Emit `examples` instead of
`example` when a plural group survives projection; never wrap schema values
in media-level Example Objects. Keep the schema-examples model and import
round-trip coverage aligned.
Import carries schema/media provenance through `openapi:example:scope`;
select each scope's group and representative before rendering. An entirely
excluded group suppresses synthesis. Apply examples by replacement so repeated
nullable-overlay visits cannot append the same group twice.

- The IR analyzer consumes prepared examples for bodies, nested parameters,
response headers and async messages. Track the captured example position
separately from structural schema authority: a neutral reusable schema can
Expand Down
8 changes: 8 additions & 0 deletions .agents/skills/loom-framework/references/formal-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ The [documentation schema admission model](../../../../expr/tla/documentation_sc
separates valid documentation schemas from unsupported generated codecs and
checks that validation visits cannot leak between those contexts.

## HTTP media contracts

The [media contract models](../../../../internal/httpcodec/tla/media_contracts/README.md)
check most-specific selection, concrete publication, and agreement between
physical branch declarations and conversion contexts. The inherited-layout
control reproduces the former failure to honor an explicitly native child layout.

## Unary admission

The [concurrency model](../../../../admission/tla/concurrency/README.md) checks
Expand Down Expand Up @@ -253,6 +260,7 @@ Name-allocation assumptions are not proved merely by proving declaration lookup.
| Code concern | Model |
| --- | --- |
| Nested synthesized union selection and occurrence-specific example caches | [Nested union examples](../../../../http/codegen/openapi/internal/ir/tla/nested_union_examples/README.md) |
| Ordered schema examples, explicit nulls and duplicate values | [Schema examples](../../../../http/codegen/openapi/internal/ir/tla/schema_examples/README.md); representative-only counterexample and bounded sequence preservation, with rendered/import tests for concrete projection and serialization |
| Alias-bound intersection and decoded Bytes length projection into base64 schema branches | `AliasLengthBounds.lean` and `ByteLengthProjection.lean` in the [Lean value model](../../../../expr/lean/value_projection/README.md); [production owners and checks](../../../../internal/valuecontract/BYTE_SCHEMA.md) distinguish proved arithmetic from tested codec extraction and schema allocation |
| One wire representation for byte/text branch comparison | [Byte example projection](../../../../http/codegen/openapi/internal/ir/tla/byte_examples/README.md) |
| Security binding ownership, explicit reservations and deterministic component names | [Security bindings](../../../../http/codegen/openapi/internal/ir/tla/security_bindings/README.md) |
Expand Down
12 changes: 12 additions & 0 deletions .agents/skills/loom/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ request decoder accepts valid `+json` media everywhere; services still implement
media-specific semantics. Do not combine static request media with a mapped
Content-Type header, form/multipart, raw, JSON-RPC or WebSocket framing.

For distinct HTTP representations, map each selected `Body` union branch with
`MediaType(media, branch)` in the endpoint and response. Select a generated union
constructor in application code. Exact media are automatic; ranges require a
mapped concrete Content-Type. Factories receive only the selected branch, and
unknown codecs remain application-owned. See [HTTP media contracts](../../../docs/http-media-contracts.md).

Use this skill when an application consumes `github.com/CaliLuke/loom`: writing
or changing `design/*.go`, regenerating `gen/`, implementing service methods,
or wiring generated transports and runtime packages.
Expand Down Expand Up @@ -407,6 +413,12 @@ Author text or byte values as usual; examples encode them once. Incomplete
object examples are omitted; explicit null examples are retained for nullable
schemas. These rules also apply to nested
parameter schemas, response-header schemas, and streaming message schemas.
Multiple schema examples retain declaration order, duplicates and explicit nulls
in `examples`; a single value uses `example`. Schema entries are JSON values,
not named Example Objects. Regenerate OpenAPI to adopt this preservation.
Import preserves schema and media example ownership with
`Meta("openapi:example:scope", "schema")` or `"media"` inside `Example`.
Keep these markers when editing imported designs to avoid merging the groups.
Synthesized tagged-union examples retain every nested envelope, including when
branches share the same shape. Configured discriminator/value keys and branch
tags apply at each level; authored examples keep their existing interpretation.
Expand Down
34 changes: 27 additions & 7 deletions codegen/go_transform_fallibility.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,18 +89,38 @@ func transformContextLayout(ctx *AttributeContext) transformLayout {
}

func transformPresenceContext(source, target *expr.AttributeExpr, ta *TransformAttrs) *TransformAttrs {
if !attributeUsesJSONPresence(source, ta.SourceCtx) && !attributeUsesJSONPresence(target, ta.TargetCtx) {
sourceContext := transformPhysicalContext(source, ta.SourceCtx)
targetContext := transformPhysicalContext(target, ta.TargetCtx)
if sourceContext == ta.SourceCtx && targetContext == ta.TargetCtx {
return ta
}
nested := *ta
nested.SourceCtx, nested.TargetCtx = ta.SourceCtx.Dup(), ta.TargetCtx.Dup()
if attributeUsesJSONPresence(source, ta.SourceCtx) {
nested.SourceCtx.JSONPresence, nested.SourceCtx.CollectionElementPresence = true, true
nested.SourceCtx, nested.TargetCtx = sourceContext, targetContext
return &nested
}

func transformPhysicalContext(attribute *expr.AttributeExpr, context *AttributeContext) *AttributeContext {
if attribute == nil || context == nil {
return context
}
if attributeUsesJSONPresence(target, ta.TargetCtx) {
nested.TargetCtx.JSONPresence, nested.TargetCtx.CollectionElementPresence = true, true
named, ok := attribute.Type.(expr.UserType)
if !ok {
return context
}
return &nested
key := named.Hash()
presence, found := context.JSONPresenceTypes[key]
if !found {
return context
}
nested := context.Dup()
nested.JSONPresence, nested.CollectionElementPresence = presence, presence
if pointer, found := context.PresencePointerTypes[key]; found {
nested.Pointer = pointer
}
if defaults, found := context.PresenceUseDefaultTypes[key]; found {
nested.UseDefault = defaults
}
return nested
}

func transformCallArguments(source, target *expr.AttributeExpr, value string, ta *TransformAttrs) []jen.Code {
Expand Down
8 changes: 0 additions & 8 deletions codegen/go_transform_presence.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,3 @@ func nativeObjectFieldPointer(parent *expr.MappedAttributeExpr, name string, att
func isAnyPresenceAttribute(attribute *expr.AttributeExpr) bool {
return attribute != nil && expr.IsAny(attribute.Type)
}

func attributeUsesJSONPresence(attribute *expr.AttributeExpr, context *AttributeContext) bool {
if attribute == nil || context == nil || len(context.JSONPresenceTypes) == 0 {
return false
}
userType, ok := attribute.Type.(expr.UserType)
return ok && context.JSONPresenceTypes[userType.Hash()]
}
16 changes: 16 additions & 0 deletions codegen/json_boundary_transform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,19 @@ func TestTransformNamedJSONCollectionBoundary(t *testing.T) {
})
}
}

func TestTransformNamedNativeBoundaryInsideJSON(t *testing.T) {
named := &expr.UserTypeExpr{TypeName: "FormVariant", AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{{Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}}}}
source := &expr.AttributeExpr{Type: named}
target := expr.DupAtt(source)
scope := NewNameScope()
sourceContext := NewAttributeContext(true, false, false, "", scope)
sourceContext.JSONPresence = true
sourceContext.JSONPresenceTypes = map[string]bool{named.Hash(): false}
targetContext := NewAttributeContext(false, false, true, "", scope)
plan, err := GoTransform(source, target, "body", "payload", sourceContext, targetContext, "unmarshal", true)
require.NoError(t, err)
require.NotContains(t, plan.Code, ".Value()")
require.Contains(t, plan.Code, "Name: body.Name")
require.True(t, sourceContext.JSONPresence)
}
8 changes: 8 additions & 0 deletions docs/dsl-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -921,6 +921,14 @@ encoding for byte fields, including fields authored as text and nested
collections. This applies to body schemas, nested parameter schemas,
response-header schemas, and `x-loom-async` message schemas
in both output formats. Explicit null examples remain null for nullable schemas.
Multiple schema examples are emitted as an ordered JSON Schema `examples` array,
including duplicate values and explicit nulls. These are JSON values, without
the names and descriptions used by media-level Example Objects. A single example
continues to use `example`. Regenerate OpenAPI to preserve all authored values.
Imported examples retain their original schema or media scope through
`Meta("openapi:example:scope", "schema")` or `"media"` inside `Example`.
This keeps re-import from combining two independently authored example groups.
Unscoped examples remain available to both schema and media documentation.
Synthesized tagged-union examples retain a discriminator/value envelope at
every nesting level, including unions that reuse the same branch shapes in
objects, arrays, and maps. Each envelope uses its configured field names and
Expand Down
112 changes: 112 additions & 0 deletions docs/http-media-contracts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Distinct HTTP media contracts

Use `MediaType(media, branch)` when one HTTP body has different representations.
The service value is an ordinary typed union. HTTP chooses the branch from the
request or response `Content-Type` and encodes only that branch's value. It does
not send the union's JSON discriminator envelope.

```go
var Message = Type("Message", func() {
OneOf("body", func() {
Attribute("document", Int)
Attribute("text", String)
})
Required("body")
})

var _ = Service("messages", func() {
Method("exchange", func() {
Payload(Message)
Result(Message)
HTTP(func() {
POST("/messages")
Body("body")
MediaType("application/json", "document")
MediaType("text/plain", "text")
Response(StatusOK, func() {
Body("body")
MediaType("application/json", "document")
MediaType("text/plain", "text")
})
})
})
})
```

Regenerate the service and transports. Callers and service implementations select
a generated union constructor, such as `NewBodyText("hello")`. The generated
client publishes `text/plain`; the server decodes the text branch, validates it,
and passes the typed union to the service. A returned text branch produces a
text response. `Accept` does not select or change the returned branch.

Each branch must have exactly one media mapping. Equal schemas can have separate
branches with separate examples. Examples on an enclosing payload or result are
projected to their selected branch; examples authored directly on a branch take
precedence. Ordinary union JSON serialization and single-media endpoints keep
their existing behavior.

## Ranges and parameters

Incoming selection prefers an exact type, then `type/*`, then `*/*`. Within one
type specificity, more matching authored parameters take precedence. Additional
incoming parameters are allowed. Equally specific overlapping declarations are
rejected. A parameter authored with an empty value still requires that parameter.
Media type names and charset values are case insensitive.

A mapped `Content-Type` must be a non-nullable string. A range requires this
field on the payload or result envelope:

```go
Header("content_type:Content-Type")
MediaType("application/*", "binary")
```

The caller or service must supply a concrete content type for that branch. It
must select the same branch under the incoming matching rules. An exact mapping
supplies its own content type when no mapped value is supplied. A mapped value
that would select another branch fails before encoding. OpenAPI describes this
selection through its content map, without an ignored `Content-Type` parameter.

Missing, malformed, wildcard, and unmatched incoming content types are rejected.
An optional absent request body needs no content type. Empty text, binary, and form
bodies remain supplied values when their content type is present. An empty form
selects an object with all optional fields absent. JSON empty
input represents absence; JSON null follows the selected branch's null contract.

## Codecs and encoding

The generated transports call the configured encoder or decoder factory after
selection and pass only the selected typed branch. Built-in factories support
JSON (including `+json`), text, octet streams, URL-encoded forms, and multipart.
XML, GOB, and other concrete media types require an application codec factory;
they do not silently fall back to JSON. A range itself does not select a codec.

A custom error formatter replaces the declared error body and uses the configured
encoder directly, as it does for single-media errors.

Text requires a non-nullable string or byte branch. Octet streams require a
non-nullable byte branch. Form and multipart variants
require non-nullable objects without untagged unions. Form fields use the existing
scalar, repeated-array, and JSON-object field rules. Multipart records each
part's concrete content type. Per-part media ranges and lists are rejected because
there is no per-part concrete-type selector. Use the existing `openapi:encoding:<field>:contentType`,
`style`, `explode`, and `allowReserved` metadata on that branch to describe an
equivalent encoding; incompatible declarations fail validation. Custom part
headers and incompatible field serialization remain outside the import subset.

Do not combine `MediaType` with static `ContentType`, documentation-only body
mappings, raw-body flags, endpoint-wide `FormRequest` or `MultipartRequest`, or
streaming/JSON-RPC framing. Forms belong to their individual media branches.
The enclosing union cannot be nullable; individual JSON branches may be nullable.

## OpenAPI import

The importer preserves each request and response content entry as a distinct
named branch, including entries with equal schemas. Media examples and field
encoding stay on their own branch. Ranges add a mapped content-type field so
application code can select a concrete representation. Re-import and regenerate
to replace the former shared-schema raw request body with typed variants.

Multi-media form and multipart root maps are rejected; these variants require
structured object schemas. This replaces the former shared-schema raw fallback
for multi-media maps. Single-media root-map imports keep their existing behavior.
19 changes: 14 additions & 5 deletions docs/openapi-import-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ so other uses of the base keep their field names and constraints. Re-import and
regenerate affected designs, then use the generated Go selectors in service code.
This does not assign protobuf tags or change protobuf field-name admission.

Multiple request and response media types retain independent schemas, examples,
and encoding contracts. They render as typed service unions with HTTP
`MediaType(media, branch)` mappings, including equal schemas under different
media types. Exact types outrank subtype and global ranges. Ranges expose a mapped
concrete Content-Type field. The generated client/server select a codec for the
selected branch; unknown codecs require an application factory. Single-media
behavior is unchanged. See [HTTP media contracts](http-media-contracts.md).

The following table summarizes fixed fields on objects that the importer
reads. Specification extensions are patterns, not fixed fields.

Expand All @@ -87,7 +95,7 @@ reads. Specification extensions are patterns, not fixed fields.
| Components | Supported schemas, parameters, request bodies, responses, headers, security schemes, and examples | None | Links, callbacks, path items, media types, and `x-*` component entries |
| Parameter | Identity, location, requiredness, schema, description, supported style, equivalent `explode` defaults, `allowReserved`, named query-map JSON `content`, and supported `x-*` | `deprecated`, unsupported examples | Incompatible serialization or content |
| Request Body | `description`, `required`, supported content, and supported `x-*` | None | unresolved, cyclic, or wrong-kind `$ref` plans |
| Media Type | Supported schema, examples, and equivalent URL-encoded field encoding | OpenAPI 3.2 `description` | `itemSchema`, unsupported encoding fields, `prefixEncoding`, and `x-*` |
| Media Type | Distinct per-media schemas and examples, ranges, equivalent form field encoding, and multipart part content types | OpenAPI 3.2 `description` | `itemSchema`, unsupported encoding fields, `prefixEncoding`, and `x-*` |
| Responses | Concrete status-code entries | None | `default` and `x-*` |
| Response | `summary`, `description`, supported headers and content, and supported `x-*` | None | Unresolved, cyclic, or wrong-kind `$ref` plans and links |
| Header | `description`, `required`, schema, `style: simple`, and `allowReserved` | `deprecated`, unsupported examples | Other serialization, content, unresolved/cyclic/wrong-kind `$ref`, and `x-*` |
Expand Down Expand Up @@ -315,12 +323,13 @@ The following constructs are conditional:
maps to a typed root map. A body-only optional form also emits
`OptionalRequestBody()`.
- A form map combined with transport parameters uses an object payload and a
selected `Body(...)` attribute. Multi-media and optional multipart maps use
the documented raw-body contract instead of inventing codec policy.
- A form schema containing an unconstrained value, including a free-form
selected `Body(...)` attribute. Optional single-media multipart maps use
the documented raw-body contract. Multi-media form/multipart variants require
structured object schemas.
- A single-media form schema containing an unconstrained value, including a free-form
`additionalProperties: true` map, stays raw because typed form decoding
cannot reconstruct JSON-valued data.
- An optional form object with required members also stays raw so an absent
- An optional single-media form object with required members also stays raw so an absent
body does not spuriously fail nested required-field validation.

Loom rejects boolean `false` schemas, constrained schemas without an explicit
Expand Down
Loading
Loading