Skip to content

Commit 7f4333b

Browse files
dmealingclaude
andcommitted
Merge origin/main into sp-g-registry-conformance (#37 flattened corpus) — clean (0 conflicts)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2 parents 91723f1 + f60986a commit 7f4333b

52 files changed

Lines changed: 3130 additions & 41 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/superpowers/plans/2026-05-31-metamodel-batch-migrate-ts-plan.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,36 @@
88
**Created:** 2026-05-31.
99
**Sister plan:** [metadata + codegen workstream](2026-05-31-metamodel-batch-metadata-codegen-plan.md) — runs in parallel on a separate session. **The coordination contract is "metadata lands first per FR";** see [Coordination contract](#coordination-contract) below.
1010

11+
---
12+
13+
> ## 🆕 2026-06-03 HANDOFF — FR-017 Tier 4 per-port fan-out (Java / Kotlin / Python) — DO THIS HERE
14+
>
15+
> **Why this is in this doc:** this is the "other machine" workstream. FR-017 TPH
16+
> (table-per-hierarchy polymorphic codegen) is fully landed in the **TypeScript
17+
> reference** + both **cross-port conformance corpora** are authored and TS-green.
18+
> The remaining work is the **per-port fan-out**, split by machine:
19+
> - **C# is being done on the primary machine** (in progress there — do NOT start C# here).
20+
> - **THIS session/machine: do Java, Kotlin, and Python.**
21+
>
22+
> **What's already shipped (read first):**
23+
> - Design + status: [FR-017 spec](../specs/2026-06-02-fr-017-tph-polymorphic-codegen-design.md) → see the **Realization status** section (Tier-4 TS foundations + Tier-5 corpora).
24+
> - **The oracle (do not modify — make each port match it):**
25+
> - api-contract: `fixtures/api-contract-conformance/tph/` (Auth base + BridgeAuth/CopayAuth/PriorAuthAuth, 4 scenarios, both lanes).
26+
> - persistence: `fixtures/persistence-conformance/queries/tph-*.yaml` (4 scenarios) + `Auth`/subtypes in `canonical/meta.fitness.json` + the committed `canonical/schema.postgres.sql` (single `auths` table). The query DSL gained `create`/`update` ops + an `expectError` flag — each port's persistence runner must implement these.
27+
> - **migrate-ts TPH single-table EMISSION (Task C.1 + C.2 below) is DONE**`expected-schema.ts` now folds subtype-only columns (nullable) into the single base table and skips subtype tables; the canonical `schema.postgres.sql` is regenerated + drift-checked green. The verify-time **drift rules (C.3/C.4 — `WARN_DISCRIMINATOR_VALUE_UNKNOWN` etc.) remain open** in this plan. Schema is TS-owned per ADR-0015, so **no per-port schema work** — every port executes the committed `schema.postgres.sql`.
28+
> - **TS reference to mirror** (per the cross-language-porting skill — mirror, don't re-derive): `server/typescript/packages/codegen-ts/src/templates/{tph-discriminator,drizzle-schema,routes-file}.ts`, `runtime-ts/src/drizzle-fastify/index.ts` (the `discriminator: {column,value}` option), and `runtime-ts/src/{tph.ts,object-manager.ts}` (effective-children resolution + discriminator inject/scope/strip — the runtime-layer reference).
29+
>
30+
> **Per-port work (each of Java / Kotlin / Python), ~1 week each, parallel-friendly:**
31+
> 1. **Codegen TPH** in the port's idiom (FR-017 §"Per-port idiom table"): JPA `@Inheritance(SINGLE_TABLE)`+`@DiscriminatorColumn`/`@DiscriminatorValue` (Java `codegen-spring`); Kotlin sealed classes + Exposed (`codegen-kotlin`); SQLAlchemy `polymorphic_on` + Pydantic + FastAPI (Python). Single-table storage; per-subtype routes at `/<base>/<discriminatorValue lowercased>`.
32+
> 2. **Runtime/data-access TPH** so the port passes the `tph-*` persistence scenarios (inherited fields/identity/single-table resolution + discriminator inject/scope/strip + the new `create`/`update`/`expectError` DSL ops in the port's persistence-conformance runner). NB: Java/Kotlin/Python persistence-conformance runs the **generated** controller + a consumer-supplied repo seam per the api-contract README — confirm how each port's query runner executes writes.
33+
> 3. **Conformance, both corpora:** make the port green against `api-contract-conformance/tph/` (reference lane + GENERATED artifact over HTTP) and `persistence-conformance/queries/tph-*.yaml`. Until a port's slice lands it **skips** `tph-*` (the m2n-* convention) — do not delete fixtures.
34+
>
35+
> **Cross-port invariants (byte-identical):** single `auths` table, subtype cols nullable; `GET /auths` polymorphic union + `GET|POST|PATCH|DELETE /auths/{bridge|copay|priorauth}` per subtype (segment = `@discriminatorValue` lowercased); discriminator injected from the URL on create (never the body); cross-subtype get/delete → 404; response always carries the discriminator field by value.
36+
>
37+
> Use `superpowers:subagent-driven-development` to fan the three ports out. Each is gated by the corpus (the oracle), TDD-first.
38+
39+
---
40+
1141
**Scope of this plan:** the `migrate-ts` slices across the four FRs. Three FRs contribute migrate-ts work; one (FR-013) has none.
1242

1343
| FR | migrate-ts work | Size |

docs/superpowers/specs/2026-06-02-fr-017-tph-polymorphic-codegen-design.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,5 +419,43 @@ New TPH query scenarios:
419419
`<Sub>SortAllowlist`, discriminator excluded); per-subtype routes wire to
420420
them. The base allowlist keeps the discriminator (polymorphic filter).
421421
- codegen-ts + codegen-ts-tanstack + codegen-ts-react suites: 652/0.
422-
- **Tiers 4–5:** unimplemented. Pick up as adopter need + per-port fan-out
423-
pressure dictates.
422+
- **Tier 5 (conformance corpora):** **authored + TS-green** (both corpora).
423+
- **api-contract** `fixtures/api-contract-conformance/tph/` — Auth base +
424+
BridgeAuth/CopayAuth/PriorAuthAuth over one `auths` table; 4 scenarios
425+
(polymorphic list/get, per-subtype list/create, per-subtype update/delete,
426+
cross-subtype 404). TS runs **both lanes** (hand-rolled reference +
427+
GENERATED `Auth.routes.ts`) over one Testcontainers Postgres per scenario per
428+
lane — **8/8**. The per-subtype URL segment is the `@discriminatorValue`
429+
lowercased (`/auths/bridge`); discriminator injected from the URL on create,
430+
never the body; cross-subtype get/delete → 404. Decimal subtype values are
431+
not asserted over the API wire (cross-port numeric formatting) — pinned by
432+
persistence instead.
433+
- **persistence** `fixtures/persistence-conformance/queries/tph-*.yaml` — 4
434+
runtime scenarios (insert-then-find-by-id, insert-three-subtypes-list,
435+
update-subtype-only-column, no-cross-subtype-update). Required extending the
436+
query DSL with `create`/`update` write ops + an `expectError` assertion.
437+
`Auth` + subtypes added to the canonical fitness meta; canonical
438+
`schema.postgres.sql` regenerated (one `auths` table, subtype cols nullable,
439+
type CHECK) — drift-check green. Full TS query suite **21/21**.
440+
- **Tier 4 (per-port) — TS-side foundations shipped; non-TS fan-out remains.**
441+
Building the persistence corpus surfaced that TPH at the **runtime** layer is a
442+
real feature the codegen tiers didn't need, now shipped for TS:
443+
- **runtime-ts ObjectManager TPH** (commit on this branch): switched the
444+
persistence resolution paths (query-builder `resolvePkFields`/`listFieldNames`/
445+
`getField`, identity-strategy, type-coercer, validator-runner, metadata
446+
`resolveTableName`/`buildNameMap`) from `ownChildren()` to the **effective**
447+
`children()` super-chain, so a subtype resolves the base's identity / single
448+
table / full field set; added `src/tph.ts` + discriminator inject (create) /
449+
scope (read/update/delete) / strip (immutable) mirroring `drizzle-fastify`.
450+
12 TDD tests; runtime-ts 317/0; metadata*/codegen-ts/migrate-ts regression
451+
green (* one PRE-EXISTING registry-coverage-drift failure, not from this work).
452+
- **migrate-ts TPH single-table schema emission** (commit on this branch): a
453+
subtype emits no table; the discriminator base folds subtype-only columns in
454+
as nullable. (Schema is TS-owned per ADR-0015, so non-TS ports execute this
455+
committed DDL — they need NO schema-emission work.)
456+
- **Remaining per-port (Java / Kotlin / Python / C#):** each needs (a) runtime
457+
TPH in its ObjectManager-equivalent (Exposed / OMDB / SQLAlchemy / EF) to pass
458+
the `tph-*` persistence scenarios, and (b) codegen TPH (entity/routes/
459+
repository/dto in the port's idiom per the Tier-4 idiom table) + reference-lane
460+
routes to pass the api-contract `tph/` corpus in both lanes. ~1 week/port,
461+
parallelizable. Ports skip `tph-*` until their slice lands (m2n-* convention).
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# `api-contract-conformance/tph/` — FR-017 TPH polymorphic CRUD corpus
2+
3+
Cross-port REST contract for **table-per-hierarchy (TPH) polymorphic CRUD** over
4+
HTTP. A discriminator-bearing base entity (`@discriminator`) and its concrete
5+
subtypes (`@discriminatorValue`) share ONE physical table; the generated routes
6+
expose a polymorphic collection at the base path plus a full per-subtype CRUD set
7+
scoped by the discriminator.
8+
9+
```
10+
GET {prefix}/auths → polymorphic list (union; each row tagged by `type`)
11+
GET {prefix}/auths/:id → polymorphic get (one row of whatever subtype)
12+
GET {prefix}/auths/<sub> → per-subtype list (filtered to that subtype)
13+
GET {prefix}/auths/<sub>/:id → per-subtype get (404 if the row is a different subtype)
14+
POST {prefix}/auths/<sub> → per-subtype create (discriminator injected from URL, NOT body)
15+
PATCH {prefix}/auths/<sub>/:id → per-subtype update (404 cross-subtype; discriminator never changes)
16+
DELETE {prefix}/auths/<sub>/:id → per-subtype delete (404 cross-subtype)
17+
```
18+
19+
The per-subtype URL segment is the **`@discriminatorValue` lowercased**
20+
`"Bridge"``/auths/bridge`, `"PriorAuth"``/auths/priorauth` — NOT the subtype
21+
entity name. There is intentionally **no polymorphic `POST /auths`** (you can't
22+
create an abstract base).
23+
24+
## The three cross-port invariants (must hold byte-identically)
25+
26+
1. **Single-table TPH storage.** One physical `auths` table; every subtype-only
27+
column (`quantity` / `copay_amount` / `approver`) is **nullable** (a row of any
28+
other subtype stores NULL there), even when the field is `@required`.
29+
2. **Polymorphic GET / per-subtype POST URLs.** `GET /auths` returns the union;
30+
`POST /auths/bridge` etc. create per subtype. The URL paths are identical
31+
cross-port.
32+
3. **Response shape carries the discriminator by value.** Every returned row
33+
carries its `type` field set to the subtype's discriminator value.
34+
35+
## Files
36+
37+
```
38+
tph/
39+
├── README.md # this file
40+
├── meta.json # Auth (base, @discriminator "type") + BridgeAuth / CopayAuth / PriorAuthAuth
41+
├── seed.json # 3 seed rows (one per subtype) into the single `auths` table
42+
└── scenarios/
43+
├── tph-polymorphic-list-and-get.yaml
44+
├── tph-per-subtype-list-and-create.yaml
45+
├── tph-per-subtype-update-and-delete.yaml
46+
└── tph-cross-subtype-404.yaml
47+
```
48+
49+
`meta.json` declares the `acme::auth` package:
50+
51+
| Entity | Discriminator | Own fields (beyond inherited `id` / `type` / `reference`) |
52+
|---|---|---|
53+
| `Auth` (base) | `@discriminator: "type"`, `@table "auths"` | `id` (long, pk), `type` (enum `Bridge`/`Copay`/`PriorAuth`), `reference` (string, required) |
54+
| `BridgeAuth` | `@discriminatorValue: "Bridge"` | `quantity` (int, required → nullable in the single table) |
55+
| `CopayAuth` | `@discriminatorValue: "Copay"` | `copayAmount` (decimal 10,2) |
56+
| `PriorAuthAuth` | `@discriminatorValue: "PriorAuth"` | `approver` (string) |
57+
58+
`seed.json` is applied fresh before every scenario (truncate-then-insert into the
59+
single `auths` table). Subtype-only columns are `null` for rows of other subtypes.
60+
61+
## Scenario shape + assertions
62+
63+
Identical to the parent `api-contract-conformance` corpus
64+
([`../README.md`](../README.md)). No new assertion shapes are needed —
65+
`ids` / `length` / `row` (subset key-match) / `error` / `empty` cover TPH.
66+
Decimal subtype values are **not** asserted over the API wire (cross-port numeric
67+
formatting differs); the runtime-layer decimal contract is pinned by
68+
`persistence-conformance` instead. The `row` assertions pin the discriminator
69+
value (`type`) and the integer/string subtype-only columns.
70+
71+
## Both lanes (the cross-port gate)
72+
73+
Each port runs these scenarios in **two lanes**, matching the m2m/SP-F fan-out:
74+
75+
1. **Reference lane** — a hand-rolled server mounting the polymorphic +
76+
per-subtype routes directly.
77+
2. **Generated lane** — the port's EMITTED TPH routes booted over HTTP (the
78+
deployed artifact, not a stand-in).
79+
80+
TS reference runner: `server/typescript/packages/integration-tests/test/api-contract-tph.test.ts`
81+
(both lanes; one Testcontainers Postgres per scenario per lane). Other ports
82+
mirror against this corpus in their Tier 4 slice.
83+
84+
## Why this is separate from `persistence-conformance/tph-*`
85+
86+
This corpus exercises the **URL grammar + HTTP wire shape** of polymorphic CRUD.
87+
The runtime persistence half (single-table insert/find/update semantics, decimal
88+
normalization, no-cross-subtype-update) is pinned by the `persistence-conformance`
89+
`tph-*` query scenarios. A port can land routes-only or runtime-only first, so the
90+
two corpora gate independently.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme::auth",
4+
"children": [
5+
{ "object.entity": {
6+
"name": "Auth",
7+
"@discriminator": "type",
8+
"children": [
9+
{ "source.rdb": { "@table": "auths" } },
10+
{ "field.long": { "name": "id", "@filterable": true, "@sortable": true } },
11+
{ "field.enum": { "name": "type", "@values": ["Bridge", "Copay", "PriorAuth"], "@filterable": true } },
12+
{ "field.string": { "name": "reference", "@required": true, "@maxLength": 80, "@filterable": true, "@sortable": true } },
13+
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
14+
]
15+
}},
16+
{ "object.entity": {
17+
"name": "BridgeAuth",
18+
"extends": "Auth",
19+
"@discriminatorValue": "Bridge",
20+
"children": [
21+
{ "field.int": { "name": "quantity", "@required": true, "@filterable": true, "@sortable": true } }
22+
]
23+
}},
24+
{ "object.entity": {
25+
"name": "CopayAuth",
26+
"extends": "Auth",
27+
"@discriminatorValue": "Copay",
28+
"children": [
29+
{ "field.decimal": { "name": "copayAmount", "@precision": 10, "@scale": 2, "@filterable": true } }
30+
]
31+
}},
32+
{ "object.entity": {
33+
"name": "PriorAuthAuth",
34+
"extends": "Auth",
35+
"@discriminatorValue": "PriorAuth",
36+
"children": [
37+
{ "field.string": { "name": "approver", "@maxLength": 80, "@filterable": true, "@sortable": true } }
38+
]
39+
}}
40+
]
41+
}
42+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: tph-cross-subtype-404
2+
description: >
3+
FR-017 Tier 4/5 TPH: a subtype-scoped route addresses ONLY rows of that
4+
subtype. A row that exists in the single table but belongs to a different
5+
subtype is invisible through the wrong segment — get/delete return 404, never
6+
another subtype's data. The correct segment still resolves it.
7+
requests:
8+
- id: r1
9+
method: GET
10+
path: /api/auths/bridge/2
11+
expect:
12+
status: 404
13+
- id: r2
14+
method: GET
15+
path: /api/auths/copay/1
16+
expect:
17+
status: 404
18+
- id: r3
19+
method: DELETE
20+
path: /api/auths/copay/1
21+
expect:
22+
status: 404
23+
- id: r4
24+
method: GET
25+
path: /api/auths/copay/2
26+
expect:
27+
status: 200
28+
body:
29+
row: { id: 2, type: "Copay", reference: "REF-2" }
30+
- id: r5
31+
method: GET
32+
path: /api/auths?sort=id:asc
33+
expect:
34+
status: 200
35+
body:
36+
ids: [1, 2, 3]
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: tph-per-subtype-list-and-create
2+
description: >
3+
FR-017 Tier 4/5 TPH: each concrete subtype has its own URL segment
4+
(`/auths/bridge`) carved out of the single table. GET filters to that
5+
subtype; POST creates a row of that subtype — the discriminator is injected
6+
by the route handler from the URL, NEVER taken from the body (the body omits
7+
it). The created row joins the polymorphic collection.
8+
requests:
9+
- id: r1
10+
method: GET
11+
path: /api/auths/bridge?sort=id:asc
12+
expect:
13+
status: 200
14+
body:
15+
ids: [1]
16+
- id: r2
17+
method: GET
18+
path: /api/auths/bridge/1
19+
expect:
20+
status: 200
21+
body:
22+
row: { id: 1, type: "Bridge", reference: "REF-1", quantity: 5 }
23+
- id: r3
24+
method: POST
25+
path: /api/auths/bridge
26+
body:
27+
reference: "REF-NEW"
28+
quantity: 9
29+
expect:
30+
status: 201
31+
body:
32+
row: { type: "Bridge", reference: "REF-NEW", quantity: 9 }
33+
- id: r4
34+
method: GET
35+
path: /api/auths/bridge?sort=id:asc
36+
expect:
37+
status: 200
38+
body:
39+
length: 2
40+
- id: r5
41+
method: GET
42+
path: /api/auths
43+
expect:
44+
status: 200
45+
body:
46+
length: 4
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: tph-per-subtype-update-and-delete
2+
description: >
3+
FR-017 Tier 4/5 TPH: subtype-scoped mutations. PATCH /auths/bridge/:id
4+
updates a subtype-only column; DELETE removes the row from the single table
5+
(so it also leaves the polymorphic collection). The discriminator is never
6+
changed by a patch.
7+
requests:
8+
- id: r1
9+
method: PATCH
10+
path: /api/auths/bridge/1
11+
body:
12+
quantity: 50
13+
expect:
14+
status: 200
15+
body:
16+
row: { id: 1, type: "Bridge", quantity: 50 }
17+
- id: r2
18+
method: GET
19+
path: /api/auths/bridge/1
20+
expect:
21+
status: 200
22+
body:
23+
row: { id: 1, quantity: 50 }
24+
- id: r3
25+
method: DELETE
26+
path: /api/auths/bridge/1
27+
expect:
28+
status: 204
29+
body:
30+
empty: true
31+
- id: r4
32+
method: GET
33+
path: /api/auths/bridge/1
34+
expect:
35+
status: 404
36+
- id: r5
37+
method: GET
38+
path: /api/auths?sort=id:asc
39+
expect:
40+
status: 200
41+
body:
42+
ids: [2, 3]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
name: tph-polymorphic-list-and-get
2+
description: >
3+
FR-017 Tier 4/5 TPH: the discriminator base exposes a polymorphic
4+
collection. GET /auths returns the union of all subtype rows, each tagged
5+
with its discriminator value (`type`); GET /auths/:id returns one row of
6+
whatever subtype it is. Every port answers identically over HTTP.
7+
requests:
8+
- id: r1
9+
method: GET
10+
path: /api/auths?sort=id:asc
11+
expect:
12+
status: 200
13+
body:
14+
ids: [1, 2, 3]
15+
- id: r2
16+
method: GET
17+
path: /api/auths/1
18+
expect:
19+
status: 200
20+
body:
21+
row: { id: 1, type: "Bridge", reference: "REF-1", quantity: 5 }
22+
- id: r3
23+
method: GET
24+
path: /api/auths/2
25+
expect:
26+
status: 200
27+
body:
28+
row: { id: 2, type: "Copay", reference: "REF-2" }
29+
- id: r4
30+
method: GET
31+
path: /api/auths/3
32+
expect:
33+
status: 200
34+
body:
35+
row: { id: 3, type: "PriorAuth", reference: "REF-3", approver: "Dr. Smith" }
36+
- id: r5
37+
method: GET
38+
path: /api/auths/999
39+
expect:
40+
status: 404
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"auths": [
3+
{ "id": 1, "type": "Bridge", "reference": "REF-1", "quantity": 5, "copayAmount": null, "approver": null },
4+
{ "id": 2, "type": "Copay", "reference": "REF-2", "quantity": null, "copayAmount": "12.50", "approver": null },
5+
{ "id": 3, "type": "PriorAuth", "reference": "REF-3", "quantity": null, "copayAmount": null, "approver": "Dr. Smith" }
6+
]
7+
}

0 commit comments

Comments
 (0)