diff --git a/backend/app/models/formations.py b/backend/app/models/formations.py index 768556d..f4b4f58 100644 --- a/backend/app/models/formations.py +++ b/backend/app/models/formations.py @@ -76,6 +76,10 @@ class Identity(Base): signature_pattern_codes: Mapped[list] = mapped_column(JSON, nullable=False, default=list) keystone_roles_json: Mapped[dict | list | None] = mapped_column(JSON, nullable=True) youth_takeaway: Mapped[str] = mapped_column(Text, nullable=False) + # Bible 8.2.4 / doc 03 amendment (T-012, founder decision 2026-07-16): + # every identity carries an age-suitability hint alongside its youth + # takeaway, same rule and shape as library_items.age_hint. + age_hint: Mapped[str] = mapped_column(String(60), nullable=False) block: Mapped[str | None] = mapped_column(String(10), nullable=True) # high|mid|low # style archetypes only (Bible 5.7 row): encouraged, tolerated, # discouraged, tempo_rule. diff --git a/backend/app/schemas.py b/backend/app/schemas.py index da01c2f..d351133 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -264,6 +264,7 @@ class IdentityOut(BaseModel): # rather than a fixed model (mirrors LibraryItemOut.extras above). keystone_roles: list | None = Field(default=None, validation_alias="keystone_roles_json") youth_takeaway: str + age_hint: str block: Literal["high", "mid", "low"] | None # style archetypes only (Bible 5.7): encouraged/tolerated/discouraged/tempo_rule. pass_risk: dict | None = Field(default=None, validation_alias="pass_risk_json") diff --git a/backend/migrations/versions/0005_identity_age_hint.py b/backend/migrations/versions/0005_identity_age_hint.py new file mode 100644 index 0000000..1a4e236 --- /dev/null +++ b/backend/migrations/versions/0005_identity_age_hint.py @@ -0,0 +1,46 @@ +"""identities.age_hint (founder decision 2026-07-16, T-012): doc 03 section +5's identities schema had no age_hint column when T-010 shipped, so Bible +8.2.4's "every identity carries a youth takeaway line and an age-suitability +hint" rule was only honoured for library_items (patterns/deliveries/ +rotations), not identities. The founder has since decided to amend the +schema rather than leave identity cards without an age hint. + +Added nullable=False with server_default="" (rather than a bare add, doc +03's identities table already has rows in any dev DB that ran T-010's +seed): SQLite's batch-recreate for an ALTER TABLE ADD COLUMN NOT NULL needs +a value for existing rows, and scripts/seed.py's upsert-by-code re-run +overwrites every row's placeholder with its real Bible-backed value +immediately afterward, so the empty string never surfaces to a user who +runs `make seed` after upgrading. + +Built off 0003 (players.flank, T-033) in parallel with T-043's 0004 +(role-scoped join codes); T-043 merged first, so this migration was +re-pointed onto 0004 at integration merge (orchestrator resolution). + +Revision ID: 0005 +Revises: 0004 +Create Date: 2026-07-16 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0005" +down_revision: Union[str, None] = "0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("identities") as batch_op: + batch_op.add_column( + sa.Column("age_hint", sa.String(length=60), nullable=False, server_default="") + ) + + +def downgrade() -> None: + with op.batch_alter_table("identities") as batch_op: + batch_op.drop_column("age_hint") diff --git a/backend/tests/test_identity_routes.py b/backend/tests/test_identity_routes.py index 8535e3a..4bb6ec1 100644 --- a/backend/tests/test_identity_routes.py +++ b/backend/tests/test_identity_routes.py @@ -73,6 +73,7 @@ def _seed_identities() -> None: signature_pattern_codes=["A1"], keystone_roles_json=[{"role": "single_pivot", "note": "The pivot."}], youth_takeaway="A youth takeaway line.", + age_hint="U13+", block="high", pass_risk_json=None, shape_render="animated", @@ -93,6 +94,7 @@ def _seed_identities() -> None: signature_pattern_codes=[], keystone_roles_json=[{"role": "stopper_cb", "note": "The stopper."}], youth_takeaway="Another youth takeaway.", + age_hint="U11+", block="mid", pass_risk_json=None, shape_render="static", @@ -113,6 +115,7 @@ def _seed_identities() -> None: signature_pattern_codes=["B5"], keystone_roles_json=["single_pivot", "false_9"], youth_takeaway="A style youth takeaway.", + age_hint="U13+", block="high", pass_risk_json={ "encouraged": ["Short circulation"], @@ -138,6 +141,7 @@ def _seed_identities() -> None: signature_pattern_codes=[], keystone_roles_json=None, youth_takeaway="A cult corner youth takeaway.", + age_hint="U9+", block=None, pass_risk_json=None, shape_render="details_only", @@ -203,3 +207,15 @@ def test_players_can_browse_identities_too(client: TestClient) -> None: def test_identity_route_requires_authentication(client: TestClient) -> None: _seed_identities() assert client.get("/api/identities").status_code == 401 + + +def test_every_identity_carries_a_non_empty_age_hint(client: TestClient) -> None: + """T-012 (Bible 8.2.4 / doc 03 amendment): every identity, of every + kind, must serve a non-empty age_hint through the route, same rule + LibraryItemOut already enforces for patterns/deliveries/rotations.""" + _seed_identities() + coach = _coach_with_team() + identities = coach.get("/api/identities").json() + assert len(identities) == 4 + for item in identities: + assert item["age_hint"], f"{item['code']}: missing age_hint" diff --git a/backend/tests/test_seed_content.py b/backend/tests/test_seed_content.py index 55c48e5..dbf492c 100644 --- a/backend/tests/test_seed_content.py +++ b/backend/tests/test_seed_content.py @@ -133,6 +133,10 @@ def test_identity_entries_carry_the_bible_youth_and_traceability_fields(fname: s assert _word_count(item["tag_line"]) <= 25, f"{fname} {code}: tag_line over 25 words" assert item["core_idea"], f"{fname} {code}: missing core_idea" assert item["youth_takeaway"], f"{fname} {code}: missing youth_takeaway" + # T-012 (Bible 8.2.4, founder decision 2026-07-16): every identity + # carries an age-suitability hint alongside its youth takeaway, + # same rule library_items already enforces (age_hint check above). + assert item["age_hint"], f"{fname} {code}: missing age_hint" assert item["source_ref"].startswith("bible:"), f"{fname} {code}: bad source_ref" diff --git a/e2e/identity.spec.ts b/e2e/identity.spec.ts index 059c283..30635c7 100644 --- a/e2e/identity.spec.ts +++ b/e2e/identity.spec.ts @@ -173,6 +173,8 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", await expect(panel.getByTestId("identity-detail-youth-takeaway")).toContainText( "Positions before players" ); + // T-012: Bible 8.2.4's age-suitability hint, added after Youth takeaway. + await expect(panel.getByTestId("identity-detail-age-hint")).toContainText("U13+"); // A reference team's own pass_risk_json is null: the block never renders. await expect(panel.getByTestId("identity-detail-pass-risk")).toHaveCount(0); // Order in the DOM matches the template's own order. @@ -185,6 +187,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", "identity-detail-signature-patterns", "identity-detail-keystone-roles", "identity-detail-youth-takeaway", + "identity-detail-age-hint", ]); await page.getByTestId("identity-details-close").click(); await expect(page.getByTestId("identity-details-panel")).toHaveCount(0); @@ -206,6 +209,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", await page.getByTestId("identity-details-toggle").click(); await expect(page.getByTestId("identity-detail-formation")).toContainText("4-2-3-1"); await expect(page.getByTestId("identity-detail-youth-takeaway")).toBeVisible(); + await expect(page.getByTestId("identity-detail-age-hint")).toContainText("U13+"); await assertCleanPage(page, issues); }); @@ -229,6 +233,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", // Style archetypes' core_idea has no "Formation:" leading sentence, so // the template does not fabricate a Formation & shape row for them. await expect(page.getByTestId("identity-detail-formation")).toHaveCount(0); + await expect(page.getByTestId("identity-detail-age-hint")).toContainText("U13+"); await assertCleanPage(page, issues); }); @@ -251,6 +256,7 @@ test.describe("identity: five-part Section 6 template, pass-risk, cult corner", await expect(page.getByTestId("identity-detail-keystone-roles")).toHaveCount(0); await expect(page.getByTestId("identity-detail-pass-risk")).toHaveCount(0); await expect(page.getByTestId("identity-detail-signature-patterns")).toHaveCount(0); + await expect(page.getByTestId("identity-detail-age-hint")).toContainText("U11+"); await assertCleanPage(page, issues); }); diff --git a/frontend/src/identityApi.ts b/frontend/src/identityApi.ts index 8c40ca8..1a54857 100644 --- a/frontend/src/identityApi.ts +++ b/frontend/src/identityApi.ts @@ -45,6 +45,7 @@ export interface IdentityOutWire { signature_pattern_codes: string[]; keystone_roles: KeystoneRoleWire[] | null; youth_takeaway: string; + age_hint: string; block: "high" | "mid" | "low" | null; pass_risk: PassRiskWire | null; shape_render: "animated" | "static" | "details_only"; diff --git a/frontend/src/pages/IdentityPage.tsx b/frontend/src/pages/IdentityPage.tsx index 8bc7e0a..de53a1a 100644 --- a/frontend/src/pages/IdentityPage.tsx +++ b/frontend/src/pages/IdentityPage.tsx @@ -7,9 +7,10 @@ // render Details only (CLAUDE.md rule 6: no designed surface, no // invented one). Details follows the Section 6 five-part template // (Formation & shape, Core idea, Signature patterns, Keystone roles, -// Youth takeaway); style archetypes additionally show the pass-risk -// block (Bible 5.7) between Keystone roles and Youth takeaway. Copy rule -// (doc 03 section 7): identities curate, never lock. +// Youth takeaway), plus an Age hint row (Bible 8.2.4, T-012); style +// archetypes additionally show the pass-risk block (Bible 5.7) between +// Keystone roles and Youth takeaway. Copy rule (doc 03 section 7): +// identities curate, never lock. import { useEffect, useMemo, useState } from "react"; import type { Orientation } from "../board/coords"; @@ -150,7 +151,7 @@ export function IdentityPage({ orientation }: IdentityPageProps) { className="app-page-info" aria-hidden="true" data-testid="identity-info" - title="Identities curate, never lock. Pull up Browse identities to explore the great teams and style archetypes: pick one, and its signature idea plays on the board, with the formation, keystone roles, and youth takeaway in Details." + title="Identities curate, never lock. Pull up Browse identities to explore the great teams and style archetypes: pick one, and its signature idea plays on the board, with the formation, keystone roles, youth takeaway, and age hint in Details." > i @@ -286,6 +287,10 @@ export function IdentityPage({ orientation }: IdentityPageProps) {
Youth takeaway
{selection.youth_takeaway}
+Age hint
+{selection.age_hint}
+