Skip to content

Commit ee3b08a

Browse files
dmealingclaude
andcommitted
docs(agent-context): teach entity read-view first for derived read columns
The authoring skill is the surface agents read to write adopter metadata, and it was steering them wrong. It mentioned the table-primary + view-replica shape in a single parenthetical, then spent 25 lines teaching "a derived read model over a view/proc is an object.projection" — so an agent asked for a grid with a joined name or a per-row count produced a projection. That was correct advice before 0.17.0 (2026-07-18): an entity hosting a derived field silently emitted no view and read the wrong table, so a projection genuinely WAS the only way to get a grid with a derived column. #213/#214 fixed both halves and the skill never caught up. Now leads with the entity read-view — a worked example (passthrough join + count aggregate, both @filterable), the FR-024 §7 contract, the decision table from docs/features/source-kinds.md, and the two things that genuinely force a projection (renamed base columns, row-filtered views). Names the cost of choosing wrong: a second URL, a second type, a second identity, and re-stating every passthrough column, for no gain. Carries a dated note so anyone porting older advice knows why it changed. The agent-context conformance corpus is regenerated through the same `assemble()` the test calls, so the fixtures match by construction rather than by hand-editing; the diff is confined to this one skill across all four stacks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KTGT5ksntpcJDZVJ5VyXHS
1 parent 97485f6 commit ee3b08a

5 files changed

Lines changed: 250 additions & 10 deletions

File tree

  • agent-context/skills/metaobjects-authoring
  • fixtures/agent-context-conformance
    • java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring
    • java-react/expected/.claude/skills/metaobjects-authoring
    • python/expected/.claude/skills/metaobjects-authoring
    • ts-react-tanstack/expected/.claude/skills/metaobjects-authoring

‎agent-context/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,56 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one
719719
```
720720

721721
**An entity's PRIMARY source must be writable** (`table`) — read-only kinds are
722-
legal only in non-primary roles (e.g. table `primary` + view `replica` for
723-
read-through). A derived read model over a view/proc is an **`object.projection`**
722+
legal only in non-primary roles.
723+
724+
### Derived/computed columns: reach for an ENTITY READ-VIEW first
725+
726+
**Do not default to `object.projection` for a list screen, grid, or "one extra
727+
column" read.** The usual need — an entity's own rows plus a joined display name or a
728+
per-row count, filterable and sortable like any other column — is an **entity
729+
read-view**, not a projection:
730+
731+
```jsonc
732+
{ "object.entity": { "name": "Order", "children": [
733+
{ "source.rdb": { "@kind": "table", "@table": "orders" } }, // writes
734+
{ "source.rdb": { "@kind": "view", "@view": "v_order", "@role": "replica" } }, // reads
735+
// …the entity's own fields stay as they are — you re-state NOTHING…
736+
{ "field.string": { "name": "customerName", "@filterable": true, "children": [
737+
{ "origin.passthrough": { "@from": "Customer.name" } } ]}},
738+
{ "field.int": { "name": "itemCount", "@filterable": true, "children": [
739+
{ "origin.aggregate": { "@agg": "count", "@of": "OrderItem.id", "@via": "Order.items" } } ]}}
740+
]}}
741+
```
742+
743+
That is the whole change. Writes route to the table (derived fields are excluded from
744+
the write codecs); reads route to the view; `meta migrate` emits the `CREATE VIEW` from
745+
the **same assembly logic** a projection view uses — one emitter, two hosts (FR-024 §7,
746+
#213/#214). Filtering and sorting on `customerName` / `itemCount` work like any other
747+
column because the filter tier runs against the view.
748+
749+
**Reach for a projection only when one of these is true** (from
750+
`docs/features/source-kinds.md`, which carries the full decision table):
751+
752+
| Entity read-view | Projection |
753+
|---|---|
754+
| extras sit over the entity's **own** table | an independent **exposure contract** |
755+
| same trust domain — a new entity field showing up in the view is correct | a subset, **renamed** columns, versioned, or external consumers |
756+
| the base is still INSERTable and is the record of truth | keyless, multi-base, proc-backed, all-derived, or borrowed identity |
757+
758+
Two things genuinely force a projection: **renamed base columns** (a field carries one
759+
`@column` per paradigm) and **row-filtered views** (soft-delete / `WHERE status='active'`
760+
— an entity read-view exposes the whole entity; only a projection carries a row-scope
761+
`@filter`, #207).
762+
763+
Choosing a projection when a read-view would do costs a second URL, a second type, a
764+
second identity declaration, and re-stating every passthrough column — for no gain.
765+
766+
> Historical note for anyone porting older advice: before 0.17.0 (2026-07-18) an entity
767+
> hosting a derived field silently produced no view and read the wrong table, so a
768+
> projection genuinely WAS the only way to get a grid with a derived column. That is
769+
> fixed; guidance written before then is stale.
770+
771+
A derived read model that IS an independent exposure is an **`object.projection`**
724772
(FR-024): its fields `extends` entity fields (`extends: "Author.id"` — dotted
725773
child traversal, package only on the root segment) and/or carry `origin.*`
726774
children (`passthrough` / `aggregate` / `collection` / `computed` / `first`)

‎fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,56 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one
719719
```
720720

721721
**An entity's PRIMARY source must be writable** (`table`) — read-only kinds are
722-
legal only in non-primary roles (e.g. table `primary` + view `replica` for
723-
read-through). A derived read model over a view/proc is an **`object.projection`**
722+
legal only in non-primary roles.
723+
724+
### Derived/computed columns: reach for an ENTITY READ-VIEW first
725+
726+
**Do not default to `object.projection` for a list screen, grid, or "one extra
727+
column" read.** The usual need — an entity's own rows plus a joined display name or a
728+
per-row count, filterable and sortable like any other column — is an **entity
729+
read-view**, not a projection:
730+
731+
```jsonc
732+
{ "object.entity": { "name": "Order", "children": [
733+
{ "source.rdb": { "@kind": "table", "@table": "orders" } }, // writes
734+
{ "source.rdb": { "@kind": "view", "@view": "v_order", "@role": "replica" } }, // reads
735+
// …the entity's own fields stay as they are — you re-state NOTHING…
736+
{ "field.string": { "name": "customerName", "@filterable": true, "children": [
737+
{ "origin.passthrough": { "@from": "Customer.name" } } ]}},
738+
{ "field.int": { "name": "itemCount", "@filterable": true, "children": [
739+
{ "origin.aggregate": { "@agg": "count", "@of": "OrderItem.id", "@via": "Order.items" } } ]}}
740+
]}}
741+
```
742+
743+
That is the whole change. Writes route to the table (derived fields are excluded from
744+
the write codecs); reads route to the view; `meta migrate` emits the `CREATE VIEW` from
745+
the **same assembly logic** a projection view uses — one emitter, two hosts (FR-024 §7,
746+
#213/#214). Filtering and sorting on `customerName` / `itemCount` work like any other
747+
column because the filter tier runs against the view.
748+
749+
**Reach for a projection only when one of these is true** (from
750+
`docs/features/source-kinds.md`, which carries the full decision table):
751+
752+
| Entity read-view | Projection |
753+
|---|---|
754+
| extras sit over the entity's **own** table | an independent **exposure contract** |
755+
| same trust domain — a new entity field showing up in the view is correct | a subset, **renamed** columns, versioned, or external consumers |
756+
| the base is still INSERTable and is the record of truth | keyless, multi-base, proc-backed, all-derived, or borrowed identity |
757+
758+
Two things genuinely force a projection: **renamed base columns** (a field carries one
759+
`@column` per paradigm) and **row-filtered views** (soft-delete / `WHERE status='active'`
760+
— an entity read-view exposes the whole entity; only a projection carries a row-scope
761+
`@filter`, #207).
762+
763+
Choosing a projection when a read-view would do costs a second URL, a second type, a
764+
second identity declaration, and re-stating every passthrough column — for no gain.
765+
766+
> Historical note for anyone porting older advice: before 0.17.0 (2026-07-18) an entity
767+
> hosting a derived field silently produced no view and read the wrong table, so a
768+
> projection genuinely WAS the only way to get a grid with a derived column. That is
769+
> fixed; guidance written before then is stale.
770+
771+
A derived read model that IS an independent exposure is an **`object.projection`**
724772
(FR-024): its fields `extends` entity fields (`extends: "Author.id"` — dotted
725773
child traversal, package only on the root segment) and/or carry `origin.*`
726774
children (`passthrough` / `aggregate` / `collection` / `computed` / `first`)

‎fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,56 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one
719719
```
720720

721721
**An entity's PRIMARY source must be writable** (`table`) — read-only kinds are
722-
legal only in non-primary roles (e.g. table `primary` + view `replica` for
723-
read-through). A derived read model over a view/proc is an **`object.projection`**
722+
legal only in non-primary roles.
723+
724+
### Derived/computed columns: reach for an ENTITY READ-VIEW first
725+
726+
**Do not default to `object.projection` for a list screen, grid, or "one extra
727+
column" read.** The usual need — an entity's own rows plus a joined display name or a
728+
per-row count, filterable and sortable like any other column — is an **entity
729+
read-view**, not a projection:
730+
731+
```jsonc
732+
{ "object.entity": { "name": "Order", "children": [
733+
{ "source.rdb": { "@kind": "table", "@table": "orders" } }, // writes
734+
{ "source.rdb": { "@kind": "view", "@view": "v_order", "@role": "replica" } }, // reads
735+
// …the entity's own fields stay as they are — you re-state NOTHING…
736+
{ "field.string": { "name": "customerName", "@filterable": true, "children": [
737+
{ "origin.passthrough": { "@from": "Customer.name" } } ]}},
738+
{ "field.int": { "name": "itemCount", "@filterable": true, "children": [
739+
{ "origin.aggregate": { "@agg": "count", "@of": "OrderItem.id", "@via": "Order.items" } } ]}}
740+
]}}
741+
```
742+
743+
That is the whole change. Writes route to the table (derived fields are excluded from
744+
the write codecs); reads route to the view; `meta migrate` emits the `CREATE VIEW` from
745+
the **same assembly logic** a projection view uses — one emitter, two hosts (FR-024 §7,
746+
#213/#214). Filtering and sorting on `customerName` / `itemCount` work like any other
747+
column because the filter tier runs against the view.
748+
749+
**Reach for a projection only when one of these is true** (from
750+
`docs/features/source-kinds.md`, which carries the full decision table):
751+
752+
| Entity read-view | Projection |
753+
|---|---|
754+
| extras sit over the entity's **own** table | an independent **exposure contract** |
755+
| same trust domain — a new entity field showing up in the view is correct | a subset, **renamed** columns, versioned, or external consumers |
756+
| the base is still INSERTable and is the record of truth | keyless, multi-base, proc-backed, all-derived, or borrowed identity |
757+
758+
Two things genuinely force a projection: **renamed base columns** (a field carries one
759+
`@column` per paradigm) and **row-filtered views** (soft-delete / `WHERE status='active'`
760+
— an entity read-view exposes the whole entity; only a projection carries a row-scope
761+
`@filter`, #207).
762+
763+
Choosing a projection when a read-view would do costs a second URL, a second type, a
764+
second identity declaration, and re-stating every passthrough column — for no gain.
765+
766+
> Historical note for anyone porting older advice: before 0.17.0 (2026-07-18) an entity
767+
> hosting a derived field silently produced no view and read the wrong table, so a
768+
> projection genuinely WAS the only way to get a grid with a derived column. That is
769+
> fixed; guidance written before then is stale.
770+
771+
A derived read model that IS an independent exposure is an **`object.projection`**
724772
(FR-024): its fields `extends` entity fields (`extends: "Author.id"` — dotted
725773
child traversal, package only on the root segment) and/or carry `origin.*`
726774
children (`passthrough` / `aggregate` / `collection` / `computed` / `first`)

‎fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,56 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one
719719
```
720720

721721
**An entity's PRIMARY source must be writable** (`table`) — read-only kinds are
722-
legal only in non-primary roles (e.g. table `primary` + view `replica` for
723-
read-through). A derived read model over a view/proc is an **`object.projection`**
722+
legal only in non-primary roles.
723+
724+
### Derived/computed columns: reach for an ENTITY READ-VIEW first
725+
726+
**Do not default to `object.projection` for a list screen, grid, or "one extra
727+
column" read.** The usual need — an entity's own rows plus a joined display name or a
728+
per-row count, filterable and sortable like any other column — is an **entity
729+
read-view**, not a projection:
730+
731+
```jsonc
732+
{ "object.entity": { "name": "Order", "children": [
733+
{ "source.rdb": { "@kind": "table", "@table": "orders" } }, // writes
734+
{ "source.rdb": { "@kind": "view", "@view": "v_order", "@role": "replica" } }, // reads
735+
// …the entity's own fields stay as they are — you re-state NOTHING…
736+
{ "field.string": { "name": "customerName", "@filterable": true, "children": [
737+
{ "origin.passthrough": { "@from": "Customer.name" } } ]}},
738+
{ "field.int": { "name": "itemCount", "@filterable": true, "children": [
739+
{ "origin.aggregate": { "@agg": "count", "@of": "OrderItem.id", "@via": "Order.items" } } ]}}
740+
]}}
741+
```
742+
743+
That is the whole change. Writes route to the table (derived fields are excluded from
744+
the write codecs); reads route to the view; `meta migrate` emits the `CREATE VIEW` from
745+
the **same assembly logic** a projection view uses — one emitter, two hosts (FR-024 §7,
746+
#213/#214). Filtering and sorting on `customerName` / `itemCount` work like any other
747+
column because the filter tier runs against the view.
748+
749+
**Reach for a projection only when one of these is true** (from
750+
`docs/features/source-kinds.md`, which carries the full decision table):
751+
752+
| Entity read-view | Projection |
753+
|---|---|
754+
| extras sit over the entity's **own** table | an independent **exposure contract** |
755+
| same trust domain — a new entity field showing up in the view is correct | a subset, **renamed** columns, versioned, or external consumers |
756+
| the base is still INSERTable and is the record of truth | keyless, multi-base, proc-backed, all-derived, or borrowed identity |
757+
758+
Two things genuinely force a projection: **renamed base columns** (a field carries one
759+
`@column` per paradigm) and **row-filtered views** (soft-delete / `WHERE status='active'`
760+
— an entity read-view exposes the whole entity; only a projection carries a row-scope
761+
`@filter`, #207).
762+
763+
Choosing a projection when a read-view would do costs a second URL, a second type, a
764+
second identity declaration, and re-stating every passthrough column — for no gain.
765+
766+
> Historical note for anyone porting older advice: before 0.17.0 (2026-07-18) an entity
767+
> hosting a derived field silently produced no view and read the wrong table, so a
768+
> projection genuinely WAS the only way to get a grid with a derived column. That is
769+
> fixed; guidance written before then is stale.
770+
771+
A derived read model that IS an independent exposure is an **`object.projection`**
724772
(FR-024): its fields `extends` entity fields (`extends: "Author.id"` — dotted
725773
child traversal, package only on the root segment) and/or carry `origin.*`
726774
children (`passthrough` / `aggregate` / `collection` / `computed` / `first`)

‎fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -719,8 +719,56 @@ Multi-source: multiple `source.rdb` children, each with a `@role`, exactly one
719719
```
720720

721721
**An entity's PRIMARY source must be writable** (`table`) — read-only kinds are
722-
legal only in non-primary roles (e.g. table `primary` + view `replica` for
723-
read-through). A derived read model over a view/proc is an **`object.projection`**
722+
legal only in non-primary roles.
723+
724+
### Derived/computed columns: reach for an ENTITY READ-VIEW first
725+
726+
**Do not default to `object.projection` for a list screen, grid, or "one extra
727+
column" read.** The usual need — an entity's own rows plus a joined display name or a
728+
per-row count, filterable and sortable like any other column — is an **entity
729+
read-view**, not a projection:
730+
731+
```jsonc
732+
{ "object.entity": { "name": "Order", "children": [
733+
{ "source.rdb": { "@kind": "table", "@table": "orders" } }, // writes
734+
{ "source.rdb": { "@kind": "view", "@view": "v_order", "@role": "replica" } }, // reads
735+
// …the entity's own fields stay as they are — you re-state NOTHING…
736+
{ "field.string": { "name": "customerName", "@filterable": true, "children": [
737+
{ "origin.passthrough": { "@from": "Customer.name" } } ]}},
738+
{ "field.int": { "name": "itemCount", "@filterable": true, "children": [
739+
{ "origin.aggregate": { "@agg": "count", "@of": "OrderItem.id", "@via": "Order.items" } } ]}}
740+
]}}
741+
```
742+
743+
That is the whole change. Writes route to the table (derived fields are excluded from
744+
the write codecs); reads route to the view; `meta migrate` emits the `CREATE VIEW` from
745+
the **same assembly logic** a projection view uses — one emitter, two hosts (FR-024 §7,
746+
#213/#214). Filtering and sorting on `customerName` / `itemCount` work like any other
747+
column because the filter tier runs against the view.
748+
749+
**Reach for a projection only when one of these is true** (from
750+
`docs/features/source-kinds.md`, which carries the full decision table):
751+
752+
| Entity read-view | Projection |
753+
|---|---|
754+
| extras sit over the entity's **own** table | an independent **exposure contract** |
755+
| same trust domain — a new entity field showing up in the view is correct | a subset, **renamed** columns, versioned, or external consumers |
756+
| the base is still INSERTable and is the record of truth | keyless, multi-base, proc-backed, all-derived, or borrowed identity |
757+
758+
Two things genuinely force a projection: **renamed base columns** (a field carries one
759+
`@column` per paradigm) and **row-filtered views** (soft-delete / `WHERE status='active'`
760+
— an entity read-view exposes the whole entity; only a projection carries a row-scope
761+
`@filter`, #207).
762+
763+
Choosing a projection when a read-view would do costs a second URL, a second type, a
764+
second identity declaration, and re-stating every passthrough column — for no gain.
765+
766+
> Historical note for anyone porting older advice: before 0.17.0 (2026-07-18) an entity
767+
> hosting a derived field silently produced no view and read the wrong table, so a
768+
> projection genuinely WAS the only way to get a grid with a derived column. That is
769+
> fixed; guidance written before then is stale.
770+
771+
A derived read model that IS an independent exposure is an **`object.projection`**
724772
(FR-024): its fields `extends` entity fields (`extends: "Author.id"` — dotted
725773
child traversal, package only on the root segment) and/or carry `origin.*`
726774
children (`passthrough` / `aggregate` / `collection` / `computed` / `first`)

0 commit comments

Comments
 (0)