Skip to content

Commit c5905b8

Browse files
dmealingclaude
andcommitted
test(api-contract): cross-port @autoset stamping gate (ADR-0045, #203/#229)
Adds the shared api-contract-conformance gate that verifies the deployed API surface honors field.timestamp @autoset across all five ports — the "additional assurance" gate #229 asked for, now achievable because the Kotlin controller (46d8e54) and Python router (abf48d2) were fixed to stamp above the seam. Corpus (fixtures/api-contract-conformance/): - Author gains isolated autoCreatedAt (@autoset:onCreate) + autoUpdatedAt (@autoset:onUpdate) fields — existing scenarios untouched (they use subset matchers; the only equals is list-empty's []). - seed.json seeds both to an OLD "2000-01-01" sentinel (equal). - scenarios/autoset-patch.yaml: PATCH /api/authors/1 → assert fieldsNotEqual:[autoCreatedAt, autoUpdatedAt]. A field-vs-field inequality (old sentinel vs a 2026 now()) is format- AND timing-agnostic and catches BOTH the no-bump and the lost-update (rewrite-created) failure modes — no capture-once or literal timestamp matching needed. Per-port runners (test-only; no generator/product code touched): - New fieldsEqual/fieldsNotEqual matchers (raw response field-vs-field) in all five assertion vocabularies (TS/Python/Kotlin/Java/C#). - Each hand-rolled reference server stamps @autoset (insert stamps onCreate+onUpdate from one now(); update/patch bumps onUpdate, never rewrites onCreate) + serves the two new columns; seeds the OLD sentinel via DIRECT insert (not the stamping path). - Generated-lane harnesses that POST-seed (Kotlin/Java/C#) switched to a direct insert of the sentinel, so the generated controller's PATCH-stamp diverges robustly (Python/TS already seed directly). Verified: all five ports pass both lanes (reference + generated) — 26 scenarios × 2 each, including autoset-patch, with every prior scenario green. POST onCreate/onUpdate stamping stays unit-gated per port (KotlinAutoSetStampingTest, test_router_autoset, Issue203AutoSetStampingTests, etc.); this corpus gates the drift-prone update path cross-port. Refs #203, #229. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent abf48d2 commit c5905b8

19 files changed

Lines changed: 546 additions & 88 deletions

File tree

fixtures/api-contract-conformance/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ requests:
9393
| `envelope` | the response body is `{ rows, total }` (set `rowsLength` + `total`) |
9494
| `error` | the response body has `error: "<value>"` |
9595
| `empty` | the response body is empty / null (204 No Content) |
96+
| `fieldsEqual`| the response body is an object; assert the listed field names are all equal to **each other** (raw, field-vs-field — no literal, so timestamp non-determinism is a non-issue). Used by the `@autoSet` gate (#203/ADR-0045). |
97+
| `fieldsNotEqual`| the response body is an object; assert the two listed fields **differ** from each other. Used by the `@autoSet` gate (e.g. after a PATCH, `autoCreatedAt` (onCreate, preserved) != `autoUpdatedAt` (onUpdate, bumped)). |
9698

9799
Runners are responsible for normalizing `createdAt` (and any other
98100
non-deterministic fields) before comparison. The keys listed above are

fixtures/api-contract-conformance/meta.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
{ "field.string": { "name": "name", "@required": true, "@maxLength": 100, "@filterable": true, "@sortable": true } },
1111
{ "field.string": { "name": "bio", "@maxLength": 1000, "@filterable": true, "@sortable": false } },
1212
{ "field.timestamp": { "name": "createdAt", "@required": true, "@filterable": true, "@sortable": true } },
13+
{ "field.timestamp": { "name": "autoCreatedAt", "@autoSet": "onCreate" } },
14+
{ "field.timestamp": { "name": "autoUpdatedAt", "@autoSet": "onUpdate" } },
1315
{ "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }
1416
]
1517
}}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: autoset-patch
2+
description: >
3+
#203 / ADR-0045 — @autoSet timestamp stamping is honored by the deployed API
4+
surface (generated controller/routes AND the reference server). The seeded row
5+
starts with autoCreatedAt == autoUpdatedAt (both an old sentinel). A PATCH must
6+
bump the onUpdate column (autoUpdatedAt) to now() while leaving the onCreate
7+
column (autoCreatedAt) untouched — so the two diverge. Asserted as a field-vs-field
8+
inequality (format- and timing-agnostic: old sentinel vs a 2026 now()), which
9+
catches BOTH failure modes: not bumping updatedAt, and the lost-update bug of
10+
rewriting createdAt on update.
11+
requests:
12+
- id: r1
13+
method: PATCH
14+
path: /api/authors/1
15+
body:
16+
name: "Ada L."
17+
expect:
18+
status: 200
19+
body:
20+
# autoUpdatedAt bumped to now() (2026); autoCreatedAt preserved (2000 sentinel).
21+
fieldsNotEqual: [autoCreatedAt, autoUpdatedAt]
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
{
22
"rows": [
3-
{ "id": 1, "name": "Ada Lovelace", "bio": "First computer programmer", "createdAt": "2026-01-01T10:00:00" },
4-
{ "id": 2, "name": "Alan Turing", "bio": null, "createdAt": "2026-01-02T10:00:00" },
5-
{ "id": 3, "name": "Grace Hopper", "bio": "Invented the compiler", "createdAt": "2026-01-03T10:00:00" },
6-
{ "id": 4, "name": "Donald Knuth", "bio": "The Art of Computer Programming", "createdAt": "2026-01-04T10:00:00" },
7-
{ "id": 5, "name": "Edsger Dijkstra", "bio": null, "createdAt": "2026-01-05T10:00:00" }
3+
{ "id": 1, "name": "Ada Lovelace", "bio": "First computer programmer", "createdAt": "2026-01-01T10:00:00", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
4+
{ "id": 2, "name": "Alan Turing", "bio": null, "createdAt": "2026-01-02T10:00:00", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
5+
{ "id": 3, "name": "Grace Hopper", "bio": "Invented the compiler", "createdAt": "2026-01-03T10:00:00", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
6+
{ "id": 4, "name": "Donald Knuth", "bio": "The Art of Computer Programming", "createdAt": "2026-01-04T10:00:00", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" },
7+
{ "id": 5, "name": "Edsger Dijkstra", "bio": null, "createdAt": "2026-01-05T10:00:00", "autoCreatedAt": "2000-01-01T00:00:00", "autoUpdatedAt": "2000-01-01T00:00:00" }
88
]
99
}

server/csharp/MetaObjects.IntegrationTests/Api/ApiContractAssertions.cs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// ApiContractAssertions — assertion vocabulary for api-contract-conformance.
22
// The recognized keys (equals / length / ids / names / row / hasId / envelope /
3-
// error / empty) are the cross-port contract — every per-port runner must
4-
// implement them identically. See
3+
// error / empty / fieldsEqual / fieldsNotEqual) are the cross-port contract —
4+
// every per-port runner must implement them identically. See
55
// fixtures/api-contract-conformance/README.md.
6+
//
7+
// fieldsEqual / fieldsNotEqual (#203 / ADR-0045, the @autoSet gate) compare RAW
8+
// response-object fields to EACH OTHER (never a literal): fieldsEqual asserts all
9+
// listed fields equal one another; fieldsNotEqual asserts the two listed fields
10+
// differ. Field-vs-field so timestamp non-determinism is a non-issue.
611

712
namespace MetaObjects.IntegrationTests.Api;
813

@@ -144,6 +149,44 @@ public static void AssertResponse(string scenarioName, ApiRequest request, int s
144149
throw new Xunit.Sdk.XunitException(
145150
$"{scenarioName} / {request.Id}: expected numeric id in body, got: {Render(body)}");
146151
}
152+
// #203 / ADR-0045 @autoSet gate — every listed response field must equal EACH OTHER
153+
// (raw field-vs-field, no literal). Missing keys compare as null.
154+
if (want.TryGetValue("fieldsEqual", out var feObj) && feObj is List<object?> feFields)
155+
{
156+
if (body is not Dictionary<string, object?> row)
157+
throw new Xunit.Sdk.XunitException(
158+
$"{scenarioName} / {request.Id}: expected object for fieldsEqual, got: {Render(body)}");
159+
var names = feFields.Select(f => f?.ToString() ?? "").ToList();
160+
for (int i = 1; i < names.Count; i++)
161+
{
162+
row.TryGetValue(names[0], out var v0);
163+
row.TryGetValue(names[i], out var vi);
164+
if (!StructuralEquals(v0, vi))
165+
throw new Xunit.Sdk.XunitException(
166+
$"{scenarioName} / {request.Id}: expected fields [{string.Join(", ", names)}] all equal, "
167+
+ $"but {names[0]}={Render(v0)} != {names[i]}={Render(vi)}");
168+
}
169+
}
170+
// #203 / ADR-0045 @autoSet gate — the two listed response fields must DIFFER from each
171+
// other (e.g. after a PATCH, autoCreatedAt (onCreate, preserved) != autoUpdatedAt
172+
// (onUpdate, bumped)). Missing keys compare as null.
173+
if (want.TryGetValue("fieldsNotEqual", out var fneObj) && fneObj is List<object?> fneFields)
174+
{
175+
if (body is not Dictionary<string, object?> row)
176+
throw new Xunit.Sdk.XunitException(
177+
$"{scenarioName} / {request.Id}: expected object for fieldsNotEqual, got: {Render(body)}");
178+
var names = fneFields.Select(f => f?.ToString() ?? "").ToList();
179+
if (names.Count != 2)
180+
throw new Xunit.Sdk.XunitException(
181+
$"{scenarioName} / {request.Id}: fieldsNotEqual expects exactly two field names, "
182+
+ $"got [{string.Join(", ", names)}]");
183+
row.TryGetValue(names[0], out var v0);
184+
row.TryGetValue(names[1], out var v1);
185+
if (StructuralEquals(v0, v1))
186+
throw new Xunit.Sdk.XunitException(
187+
$"{scenarioName} / {request.Id}: expected fields {names[0]} and {names[1]} to DIFFER, "
188+
+ $"but both = {Render(v0)}");
189+
}
147190
}
148191

149192
/// <summary>

server/csharp/MetaObjects.IntegrationTests/Api/AuthorApiServer.cs

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,12 @@ public static async Task<AuthorApiServer> StartAsync(PostgresContainer pg)
9090
id BIGSERIAL PRIMARY KEY,
9191
name VARCHAR(100) NOT NULL,
9292
bio VARCHAR(1000),
93-
""createdAt"" TIMESTAMP NOT NULL
93+
""createdAt"" TIMESTAMP NOT NULL,
94+
-- Issue #203 / ADR-0045: the @autoSet timestamp columns (onCreate / onUpdate).
95+
-- Nullable (not @required); the seed writes them verbatim, create stamps both,
96+
-- update bumps only autoUpdatedAt.
97+
""autoCreatedAt"" TIMESTAMP,
98+
""autoUpdatedAt"" TIMESTAMP
9499
)";
95100
await cmd.ExecuteNonQueryAsync();
96101
}
@@ -135,11 +140,19 @@ public async Task ApplySeedAsync(IReadOnlyList<Dictionary<string, object?>> rows
135140
{
136141
await using var ins = c.CreateCommand();
137142
ins.CommandText =
138-
"INSERT INTO \"authors\" (id, name, bio, \"createdAt\") VALUES (@id, @name, @bio, @ct)";
143+
"INSERT INTO \"authors\" (id, name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\") "
144+
+ "VALUES (@id, @name, @bio, @ct, @ac, @au)";
139145
ins.Parameters.AddWithValue("@id", Convert.ToInt64(r["id"]));
140146
ins.Parameters.AddWithValue("@name", (string)r["name"]!);
141147
ins.Parameters.AddWithValue("@bio", r["bio"] is string b ? b : (object)DBNull.Value);
142148
ins.Parameters.AddWithValue("@ct", ParseTimestamp((string)r["createdAt"]!));
149+
// Issue #203 / ADR-0045 — the seed writes the OLD @autoSet sentinel VERBATIM (a direct
150+
// insert, NOT a stamping create) so the seeded row keeps the old value; the PATCH then
151+
// bumps autoUpdatedAt while autoCreatedAt stays old → a robust field-vs-field divergence.
152+
ins.Parameters.AddWithValue("@ac",
153+
r.GetValueOrDefault("autoCreatedAt") is string ac ? ParseTimestamp(ac) : (object)DBNull.Value);
154+
ins.Parameters.AddWithValue("@au",
155+
r.GetValueOrDefault("autoUpdatedAt") is string au ? ParseTimestamp(au) : (object)DBNull.Value);
143156
await ins.ExecuteNonQueryAsync();
144157
}
145158
await using var bump = c.CreateCommand();
@@ -261,7 +274,8 @@ private async Task ListAuthorsAsync(
261274
var bindParams = new List<object?>();
262275
string whereClause = BuildWhere(filter.Predicates, bindParams);
263276

264-
var sql = new StringBuilder("SELECT id, name, bio, \"createdAt\" FROM \"authors\"");
277+
var sql = new StringBuilder(
278+
"SELECT id, name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\" FROM \"authors\"");
265279
sql.Append(whereClause);
266280
sql.Append(" ORDER BY \"").Append(sortField).Append("\" ").Append(sortDir);
267281
if (limit is int lim) sql.Append(" LIMIT ").Append(lim);
@@ -473,7 +487,7 @@ private async Task GetAuthorAsync(HttpListenerContext ctx, string idStr)
473487
await using var c = new NpgsqlConnection(_pg.ConnectionString);
474488
await c.OpenAsync();
475489
await using var cmd = c.CreateCommand();
476-
cmd.CommandText = "SELECT id, name, bio, \"createdAt\" FROM \"authors\" WHERE id = @id";
490+
cmd.CommandText = "SELECT id, name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\" FROM \"authors\" WHERE id = @id";
477491
cmd.Parameters.AddWithValue("@id", id.Value);
478492
await using var rdr = await cmd.ExecuteReaderAsync();
479493
if (!await rdr.ReadAsync())
@@ -508,21 +522,28 @@ private async Task CreateAuthorAsync(HttpListenerContext ctx)
508522
return;
509523
}
510524
long newId;
525+
// Issue #203 / ADR-0045 — stamp BOTH @autoSet columns from ONE now(), ignoring any
526+
// caller-supplied value (a fresh row's autoUpdatedAt == its autoCreatedAt). Unspecified
527+
// kind to match the plain-TIMESTAMP columns (the seed's ParseTimestamp yields the same).
528+
var autoNow = DateTime.SpecifyKind(DateTimeOffset.UtcNow.UtcDateTime, DateTimeKind.Unspecified);
511529
await using var c = new NpgsqlConnection(_pg.ConnectionString);
512530
await c.OpenAsync();
513531
await using (var ins = c.CreateCommand())
514532
{
515533
ins.CommandText =
516-
"INSERT INTO \"authors\" (name, bio, \"createdAt\") VALUES (@name, @bio, @ct) RETURNING id";
534+
"INSERT INTO \"authors\" (name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\") "
535+
+ "VALUES (@name, @bio, @ct, @ac, @au) RETURNING id";
517536
ins.Parameters.AddWithValue("@name", body.GetValueOrDefault("name") as string ?? "");
518537
ins.Parameters.AddWithValue("@bio", body.GetValueOrDefault("bio") is string b ? b : (object)DBNull.Value);
519538
ins.Parameters.AddWithValue("@ct", ParseTimestamp((string)body["createdAt"]!));
539+
ins.Parameters.AddWithValue("@ac", autoNow);
540+
ins.Parameters.AddWithValue("@au", autoNow);
520541
newId = Convert.ToInt64(await ins.ExecuteScalarAsync());
521542
}
522543
Dictionary<string, object?> row;
523544
await using (var sel = c.CreateCommand())
524545
{
525-
sel.CommandText = "SELECT id, name, bio, \"createdAt\" FROM \"authors\" WHERE id = @id";
546+
sel.CommandText = "SELECT id, name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\" FROM \"authors\" WHERE id = @id";
526547
sel.Parameters.AddWithValue("@id", newId);
527548
await using var rdr = await sel.ExecuteReaderAsync();
528549
await rdr.ReadAsync();
@@ -572,6 +593,12 @@ private async Task UpdateAuthorAsync(HttpListenerContext ctx, string idStr)
572593
await SendJsonAsync(ctx, 400, new Dictionary<string, object?> { ["error"] = "validation" });
573594
return;
574595
}
596+
// Issue #203 / ADR-0045 — bump the onUpdate @autoSet column (autoUpdatedAt) to now();
597+
// autoCreatedAt (onCreate) is NEVER written on update (the write-once created-at contract),
598+
// so after a PATCH the two diverge. Appended AFTER the empty-body guard so a no-op PATCH
599+
// still 400s per FR-035. Unspecified kind to match the plain-TIMESTAMP column.
600+
sets.Add("\"autoUpdatedAt\" = @p" + vals.Count);
601+
vals.Add(DateTime.SpecifyKind(DateTimeOffset.UtcNow.UtcDateTime, DateTimeKind.Unspecified));
575602
int updated;
576603
await using var c = new NpgsqlConnection(_pg.ConnectionString);
577604
await c.OpenAsync();
@@ -592,7 +619,7 @@ private async Task UpdateAuthorAsync(HttpListenerContext ctx, string idStr)
592619
Dictionary<string, object?> row;
593620
await using (var sel = c.CreateCommand())
594621
{
595-
sel.CommandText = "SELECT id, name, bio, \"createdAt\" FROM \"authors\" WHERE id = @id";
622+
sel.CommandText = "SELECT id, name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\" FROM \"authors\" WHERE id = @id";
596623
sel.Parameters.AddWithValue("@id", id.Value);
597624
await using var rdr = await sel.ExecuteReaderAsync();
598625
await rdr.ReadAsync();
@@ -621,23 +648,25 @@ private async Task DeleteAuthorAsync(HttpListenerContext ctx, string idStr)
621648

622649
private static Dictionary<string, object?> RowToMap(NpgsqlDataReader rdr)
623650
{
624-
var row = new Dictionary<string, object?>(StringComparer.Ordinal)
651+
return new Dictionary<string, object?>(StringComparer.Ordinal)
625652
{
626653
["id"] = rdr.GetInt64(rdr.GetOrdinal("id")),
627654
["name"] = rdr.GetString(rdr.GetOrdinal("name")),
628655
["bio"] = rdr.IsDBNull(rdr.GetOrdinal("bio")) ? null : rdr.GetString(rdr.GetOrdinal("bio")),
656+
["createdAt"] = ReadTimestamp(rdr, "createdAt"),
657+
// Issue #203 / ADR-0045 — the @autoSet columns surface on the wire so the
658+
// autoset-patch scenario can assert autoCreatedAt (onCreate, preserved) !=
659+
// autoUpdatedAt (onUpdate, bumped) after a PATCH.
660+
["autoCreatedAt"] = ReadTimestamp(rdr, "autoCreatedAt"),
661+
["autoUpdatedAt"] = ReadTimestamp(rdr, "autoUpdatedAt"),
629662
};
630-
int ctOrd = rdr.GetOrdinal("createdAt");
631-
if (rdr.IsDBNull(ctOrd))
632-
{
633-
row["createdAt"] = null;
634-
}
635-
else
636-
{
637-
var dt = rdr.GetDateTime(ctOrd);
638-
row["createdAt"] = dt.ToString(TimestampFmt, CultureInfo.InvariantCulture);
639-
}
640-
return row;
663+
}
664+
665+
/// <summary>Read a nullable TIMESTAMP column, formatted in the wire form (no zone), or null.</summary>
666+
private static object? ReadTimestamp(NpgsqlDataReader rdr, string column)
667+
{
668+
int ord = rdr.GetOrdinal(column);
669+
return rdr.IsDBNull(ord) ? null : rdr.GetDateTime(ord).ToString(TimestampFmt, CultureInfo.InvariantCulture);
641670
}
642671

643672
private static async Task<object?> ReadJsonBodyAsync(HttpListenerContext ctx)

server/csharp/MetaObjects.IntegrationTests/Api/GeneratedAuthorServerFactory.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,12 @@ bio VARCHAR(1000),
8585
-- ADR-0036 Wave 2: a default field.timestamp is an absolute instant →
8686
-- the generated EF model binds CreatedAt to DateTimeOffset, which Npgsql
8787
-- reads from a `timestamp with time zone` (timestamptz) column.
88-
""createdAt"" TIMESTAMPTZ NOT NULL
88+
""createdAt"" TIMESTAMPTZ NOT NULL,
89+
-- Issue #203 / ADR-0045: the @autoSet timestamp columns (onCreate / onUpdate).
90+
-- Not @required → nullable DateTimeOffset? in the generated EF model; the
91+
-- generated CRUD stamps them (insert stamps both, update bumps autoUpdatedAt).
92+
""autoCreatedAt"" TIMESTAMPTZ,
93+
""autoUpdatedAt"" TIMESTAMPTZ
8994
)";
9095
await cmd.ExecuteNonQueryAsync();
9196
}
@@ -285,11 +290,19 @@ public async Task ApplySeedAsync(IReadOnlyList<Dictionary<string, object?>> rows
285290
{
286291
await using var ins = c.CreateCommand();
287292
ins.CommandText =
288-
"INSERT INTO \"authors\" (id, name, bio, \"createdAt\") VALUES (@id, @name, @bio, @ct)";
293+
"INSERT INTO \"authors\" (id, name, bio, \"createdAt\", \"autoCreatedAt\", \"autoUpdatedAt\") "
294+
+ "VALUES (@id, @name, @bio, @ct, @ac, @au)";
289295
ins.Parameters.AddWithValue("@id", Convert.ToInt64(r["id"]));
290296
ins.Parameters.AddWithValue("@name", (string)r["name"]!);
291297
ins.Parameters.AddWithValue("@bio", r["bio"] is string b ? b : (object)DBNull.Value);
292298
ins.Parameters.AddWithValue("@ct", ParseTimestamp((string)r["createdAt"]!));
299+
// Issue #203 / ADR-0045 — seed the OLD @autoSet sentinel via a DIRECT insert (NOT the
300+
// stamping generated POST) so the seeded row keeps the old value; the generated PATCH
301+
// then bumps autoUpdatedAt while autoCreatedAt stays old → the asserted divergence.
302+
ins.Parameters.AddWithValue("@ac",
303+
r.GetValueOrDefault("autoCreatedAt") is string ac ? ParseTimestamp(ac) : (object)DBNull.Value);
304+
ins.Parameters.AddWithValue("@au",
305+
r.GetValueOrDefault("autoUpdatedAt") is string au ? ParseTimestamp(au) : (object)DBNull.Value);
293306
await ins.ExecuteNonQueryAsync();
294307
}
295308
await using var bump = c.CreateCommand();

0 commit comments

Comments
 (0)