You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 46d8e54
Browse filesBrowse the repository at this point in the historyBrowse files
feat(codegen-kotlin): generated Spring controller stamps @autoset (ADR-0045, #203)
The shipped Kotlin generated Spring controller did its own inline Exposed writes
with zero @autoset awareness — so an adopter deploying it silently lost the
timestamp-stamping FR (#203 shipped it only in KotlinRepositoryGenerator, which
the controller never uses). Surfaced while scoping the cross-port @autoset gate.
ADR-0045 records the durable rule: the generated API surface (the controller/routes
an adopter deploys) must itself guarantee every metamodel write semantic — no
consumer-supplied seam may sit between the guarantee and the wire. Persistence-layer
stamping stays as defense-in-depth for non-HTTP writes.
KotlinSpringControllerGenerator now:
- insert stamps BOTH onCreate + onUpdate columns from ONE captured now()-val per
temporal type (a fresh row's createdAt == updatedAt exactly), ignoring the caller;
- @autoset columns are excluded from the PATCH-settable set (a caller can no longer
overwrite createdAt/updatedAt);
- a patch bumps every onUpdate column on EVERY request (even an empty body) and never
rewrites onCreate;
- byte-identical output for entities with no @autoset field (snapshot suite 14/14).
nowExpr hoisted to KotlinTypeMapper (the type->"Type.now()" mapping's natural home;
no circular dep, unlike KotlinGenUtil). New controller-level @autoset tests in
KotlinAutoSetStampingTest (11/11). TPH+@autoset controller stamping is a documented
follow-up (not exercised by the corpus; no existing test covers it).
This is the Kotlin leg of the ADR-0045 rollout; the Python router leg and the shared
api-contract @autoset gate follow (no scenario lands here, so no lane reds).
Refs #203, #229.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
Copy file name to clipboardExpand all lines: server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt
+50-2Lines changed: 50 additions & 2 deletions
Original file line number
Diff line number
Diff line change
@@ -179,10 +179,33 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
179
179
(it !isObjectField|| isJsonbObjectColumn(it)) &&
180
180
// #214: a DERIVED (origin.*) field on a write-through entity has no column on the
181
181
// write table — it is computed by the read view — so it is never a write/patch target.
// NOTE: a `field.string @dbColumnType=jsonb` open bag IS written here (create
416
447
// binds it from the @Valid DTO's kotlinx JsonElement property, gated by the
417
448
// jsonb-open-bag-roundtrip corpus). PATCH cannot (the raw-JsonNode path can't bind
@@ -449,9 +480,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
449
480
append(" if (body.has(\"${field.name}\") && body.get(\"${field.name}\").isNull) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
450
481
}
451
482
}
483
+
val mustStampOnUpdate = onUpdateAutoSetFields.isNotEmpty()
452
484
if (hasPatchFields) {
453
485
val settableNamesList = patchSettableFields.joinToString(", ") { "\"${it.name}\"" }
454
-
append(" if (listOf($settableNamesList).any { body.has(it) }) {\n")
486
+
// #203/ADR-0045: with onUpdate @autoSet columns the update must run on EVERY patch
487
+
// (to bump them) — no "any present" guard; otherwise the guard keeps the update out
488
+
// when the caller sent no settable field (byte-identical to the pre-#203 output).
489
+
if (!mustStampOnUpdate) append(" if (listOf($settableNamesList).any { body.has(it) }) {\n")
455
490
// A present value that cannot bind to its column's Kotlin type (e.g. a JSON object
456
491
// for a String column) throws from treeToValue — map that to 400, not a 500. Catch
457
492
// the specific Jackson JsonMappingException so no unrelated exception is swallowed.
@@ -502,6 +537,11 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
502
537
// Apply the already-bound + validated values. Columns are qualified (`Table.col`)
503
538
// so a field named like the `body`/`id` params can't shadow them.
504
539
append("${tableObjectName}.update({ ${tableObjectName}.${pkFieldName} eq id }) {\n")
540
+
// #203/ADR-0045: bump every onUpdate @autoSet column first (server-owned); onCreate
541
+
// columns are never touched on update (createdAt is immutable).
# ADR-0045: The generated API surface owns metamodel write semantics
2
+
3
+
## Status
4
+
5
+
**Accepted** (2026-07-23). Fixes the Kotlin + Python legs of `field.timestamp @autoSet` (issue #203 follow-up, #229): the shipped Kotlin generated Spring controller and Python generated FastAPI router did not stamp `@autoSet` timestamps, so an adopter deploying those artifacts silently lost a semantic the changelog says shipped in all five ports. Generalizes the FR-036 decision ("constraint validation must be enforced at the wire tier, not left decorative") from validation to *all* metamodel-guaranteed write semantics.
6
+
7
+
## Context
8
+
9
+
`field.timestamp @autoSet: onCreate|onUpdate` declares "**the generated CRUD stamps `now()` — the caller does not.**" #203 shipped this across all five ports, but a scoping pass for the cross-port parity gate (#229) found the stamping is implemented in a **different architectural layer per port**, and two ports' outermost generated write artifact does not honor it:
10
+
11
+
| Port |`@autoSet` stamped in | Generated **API-surface** artifact guarantees it? |
12
+
|---|---|---|
13
+
| TS | codegen Zod validators, called by the emitted routes | ✅ |
14
+
| C# | generated routes (`RoutesGenerator`) | ✅ |
15
+
| Java | generated controller/DTO (`stampForInsert` / `stampAutoSetOnUpdate`), *then* delegates to the consumer repository seam | ✅ |
16
+
| Python |`ObjectManager` runtime only — the generated router delegates raw DTOs to a consumer repository seam | ❌ guarantee lives *below* the seam, in a MetaObjects runtime package the adopter may not wire |
17
+
| Kotlin | generated Exposed **repository** only — the generated controller does its own inline `transaction { Table.insert{} }` writes, never uses the repository, has zero `@autoSet` awareness | ❌ shipped controller does not stamp |
18
+
19
+
The generated **API surface** (the REST controller/routes an adopter deploys) is the outermost write artifact. When the `@autoSet` guarantee lives *below* a consumer-supplied seam (Python's repository `Protocol`; Kotlin's ignored repository), a metamodel-guaranteed semantic silently becomes a property of **hand-written consumer code** — a direct violation of "pattern-derivable from metadata = codegen, never hand-code." It also breaks the substrate promise: Python's only shipped stamping is in `ObjectManager` (a MetaObjects runtime package), so an adopter who wires their own persistence into the generated router gets a deployed API that quietly drops a shipped FR. "Generated code runs without MetaObjects" must include running *with the declared semantics* without MetaObjects.
20
+
21
+
The gap survived because **no `@autoSet` scenario existed in `api-contract-conformance` or `persistence-conformance`** — the wire tier was never gated for it. This is the same class of hole FR-036 closed for constraint validation (decorative in C#/Python until the wire tier was made to enforce it).
22
+
23
+
## Decision
24
+
25
+
**The outermost generated write-path artifact an adopter deploys must itself guarantee every metamodel-declared write semantic. No consumer-supplied seam may sit between the guarantee and the wire.**
26
+
27
+
1.**API surface stamps.** A generated REST controller / route handler stamps `@autoSet` (and enforces the analogous FR-036 constraint checks) in the generated code, before any consumer-supplied persistence seam. Java is the canonical shape: stamp in the controller/DTO, *then* delegate to the repository — layering and semantic-ownership are orthogonal.
28
+
2.**Persistence-layer stamping stays** as the carrier for non-HTTP writes (the runtime pillar) and as defense-in-depth. Kotlin's generated repository, Python's `ObjectManager`, and any ORM-tier stamping are retained. Double-stamping is idempotent-harmless (two `now()` reads, same semantics). The rule is **not** "only the API layer stamps"; it is "the generated API artifact never *depends* on an unspecified layer beneath it for a metamodel-guaranteed write semantic."
29
+
3.**Conformance gates the wire tier.** The `@autoSet` write contract is gated in `api-contract-conformance` on every port's generated *and* reference lane, with **behavioral** assertions (a fresh insert's `createdAt == updatedAt`; a later update leaves `createdAt` and bumps `updatedAt`; caller-supplied `@autoSet` values are ignored) — field-vs-field comparisons, not exact-match, since `now()` is nondeterministic. The absence of such an assertion vocabulary is what let a non-stamping controller ship green.
30
+
4.**Not breaking, no new vocabulary.**`@autoSet` is an existing registered attr; this changes only *where* the guarantee is enforced in generated output. Output for entities with no `@autoSet` field is byte-identical (pinned by no-churn tests).
31
+
32
+
### Why not the alternatives
33
+
34
+
-**Rewire the Kotlin controller to delegate to the generated repository** (so the repository's stamping applies): rejected as the vehicle for this fix. The generated controller already handles TPH, #214 write-through read-views, M:N traversal, and FR-009 filters; the generated repository is FR-035 Phase-2 work with explicit gaps (no TPH polymorphic surface, no read-only view repository). Routing the controller through it is a feature-parity project that would churn all generated output and block a semantics bug on Phase-2 completion. It may be reconsidered later on its own merits; it is not this decision.
35
+
-**Leave stamping persistence-only + document it**: rejected — the shipped artifact violates a contract the changelog states shipped in all five ports. That is a bug, not a documentable choice.
36
+
-**Move *all* ports to persistence-only stamping** (make Python/Kotlin the model): rejected — it inverts the substrate promise (the deployable artifact would depend on a specific persistence layer for a declared semantic) and diverges from TS/C#/Java, where the API surface already owns it.
37
+
38
+
### Precedent alignment
39
+
40
+
-**FR-036** (constraint validation must be enforced at the wire tier across all ports) is the same call for the sibling write semantic; `@autoSet` and constraint-validation even interlock (every port's insert schema already makes an `@autoSet @required` field optional-on-POST *because* the server fills it).
41
+
-**ADR-0023** (strict provenance) is unaffected: no new vocabulary, purely where a generator enforces an existing attr.
42
+
- Ships as a coordinated bug-fix change (Kotlin controller + Python router/model + the api-contract `@autoSet` gate), landed together so no lane reds — per the repo's "never commit a scenario ports can't pass" rule.
0 commit comments