Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/stable-ids.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Stability gate for stable_id (engine#90): compares the PR head against the
# PR base, so a disappeared/reused id or a coverage regression fails BEFORE
# merge. Self-test first: a gate whose detectors were never seen firing is a
# blind gate.
name: stable-id gate

on:
pull_request:

jobs:
stable-ids:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: actions/setup-python@v6
with:
python-version: "3.12"

- uses: actions/setup-node@v4
with:
node-version: "20"

- name: Install the pinned engine (the gate ships with it)
run: npm install --no-save --no-package-lock --no-audit --no-fund "learn-content-engine@$(cat schema/engine-version.txt)"

- name: Install PyYAML
run: pip install "pyyaml>=6,<7"

- name: Stability gate against the PR base (shipped command)
run: npx --no-install learn-content-engine check-stable-ids --base origin/${{ github.base_ref }}

- name: Coverage ratchet self-test (every path must fire)
run: python scripts/check_stable_id_coverage.py --self-test

- name: Coverage ratchet
run: python scripts/check_stable_id_coverage.py
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,8 @@ audit: $(VENV)/.ready

clean:
rm -rf $(VENV)

stable-ids: $(ENGINE_STAMP) ## Stabilitaets-Gate (mitgeliefert) + Abdeckungs-Ratchet (repo-lokal)
npx --no-install learn-content-engine check-stable-ids --base origin/main
python3 scripts/check_stable_id_coverage.py --self-test
python3 scripts/check_stable_id_coverage.py
91 changes: 78 additions & 13 deletions schema/content-manifest.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"properties": {
"assets": {
"default": [],
"description": "Phase 54 / v1.37.0 \u2014 optional list of binary assets (images, audio) the set bundles. Each entry declares a relative path inside ``assets/`` and the expected size in KiB. The downloader fetches every declared asset alongside the lesson JSON; the cache stores them under ``{cache_root}/.../v{version}/assets/{path}``. The manifest validator rejects assets exceeding the per-file size limit (default 500 KiB).",
"description": "Phase 54 / v1.37.0 optional list of binary assets (images, audio) the set bundles. Each entry declares a relative path inside ``assets/`` and the expected size in KiB. The downloader fetches every declared asset alongside the lesson JSON; the cache stores them under ``{cache_root}/.../v{version}/assets/{path}``. The manifest validator rejects assets exceeding the per-file size limit (default 500 KiB).",
"items": {
"$ref": "#/$defs/ContentSetAsset"
},
Expand All @@ -24,7 +24,7 @@
}
],
"default": null,
"description": "#769 \u2014 optional set-level book block (title/author/url/asin). When present, the lesson's 'Vertiefe das Thema' section auto-inserts it as the first media item."
"description": "#769 optional set-level book block (title/author/url/asin). When present, the lesson's 'Vertiefe das Thema' section auto-inserts it as the first media item."
},
"cover_image": {
"anyOf": [
Expand Down Expand Up @@ -76,7 +76,7 @@
"title": "Domain Label"
},
"id": {
"description": "Slug-safe identifier, unique within the manifest. Convention (Phase 60 / v1.44.0): ``{target}-{level}-from-{source}`` for language sets (e.g. ``fr-a1-from-de``). Pre-v1.2 ids like ``language-fr-a1`` still load \u2014 the loader does NOT parse this, it's free-form per the EXP-005 domain-agnostic stance.",
"description": "Slug-safe identifier, unique within the manifest. Convention (Phase 60 / v1.44.0): ``{target}-{level}-from-{source}`` for language sets (e.g. ``fr-a1-from-de``). Pre-v1.2 ids like ``language-fr-a1`` still load the loader does NOT parse this, it's free-form per the EXP-005 domain-agnostic stance.",
"maxLength": 120,
"minLength": 1,
"title": "Id",
Expand Down Expand Up @@ -107,12 +107,12 @@
}
],
"default": null,
"description": "Phase 60 / v1.44.0 \u2014 repo-relative directory where the set's own ``manifest.yaml`` + ``lessons/`` + ``assets/`` live. Enables the source-language tree (e.g. ``sets/de/fr-a1`` for a French-for-German set while the id stays the flat slug ``fr-a1-from-de``). When omitted the loader falls back to the legacy ``sets/{id}`` convention. No leading/trailing slash, no ``..`` segments.",
"description": "Phase 60 / v1.44.0 repo-relative directory where the set's own ``manifest.yaml`` + ``lessons/`` + ``assets/`` live. Enables the source-language tree (e.g. ``sets/de/fr-a1`` for a French-for-German set while the id stays the flat slug ``fr-a1-from-de``). When omitted the loader falls back to the legacy ``sets/{id}`` convention. No leading/trailing slash, no ``..`` segments.",
"title": "Path"
},
"source_language": {
"default": "en",
"description": "BCP-47 code of the language the learner ALREADY SPEAKS \u2014 the language the card ``back`` fields, notes and theory text are written in. A 'French A1 for German speakers' set has ``target_language: fr`` + ``source_language: de``. Defaults to ``en`` for pre-v1.2 content (the pilot sets were authored with English explanations).",
"description": "BCP-47 code of the language the learner ALREADY SPEAKS the language the card ``back`` fields, notes and theory text are written in. A 'French A1 for German speakers' set has ``target_language: fr`` + ``source_language: de``. Defaults to ``en`` for pre-v1.2 content (the pilot sets were authored with English explanations).",
"title": "Source Language",
"type": "string"
},
Expand All @@ -132,7 +132,7 @@
"type": "string"
},
"title": {
"description": "Human-readable title shown in the Set Browser, in the learner's SOURCE language (e.g. 'Franz\u00f6sisch A1 f\u00fcr Deutschsprachige' for a fr-from-de set).",
"description": "Human-readable title shown in the Set Browser, in the learner's SOURCE language (e.g. 'Französisch A1 für Deutschsprachige' for a fr-from-de set).",
"maxLength": 200,
"minLength": 1,
"title": "Title",
Expand All @@ -149,7 +149,7 @@
}
],
"default": null,
"description": "Phase 60 / v1.44.0 \u2014 optional title in the TARGET language (e.g. 'Fran\u00e7ais A1' for a French set). Shown as a secondary native-script label alongside ``title``. The community-share validator requires it for shareable sets; bundled/legacy sets may omit it.",
"description": "Phase 60 / v1.44.0 optional title in the TARGET language (e.g. 'Français A1' for a French set). Shown as a secondary native-script label alongside ``title``. The community-share validator requires it for shareable sets; bundled/legacy sets may omit it.",
"title": "Title Native"
},
"version": {
Expand All @@ -166,6 +166,71 @@
],
"title": "Visibility",
"type": "string"
},
"attribution": {
"anyOf": [
{
"type": "object",
"additionalProperties": false,
"required": [
"author"
],
"properties": {
"author": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"title": "Author",
"description": "Display name of the person this set's content is attributed to. Attribution, NOT authorization: without accounts or a server the name is unverifiable, and the field claims nothing more."
},
"derived_from": {
"type": "array",
"maxItems": 8,
"title": "Derived From",
"description": "Bounded derivation chain, OLDEST first. Whoever edits and re-shares a foreign set moves the previous author to the END of this list and sets themselves as author; when the list is full, the FIRST entry (the origin) stays and the oldest middle entry is dropped (editor/author rule; the schema enforces only the bound).",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"author"
],
"properties": {
"author": {
"type": "string",
"minLength": 1,
"maxLength": 120,
"title": "Author"
}
}
}
}
}
},
{
"type": "null"
}
],
"default": null,
"title": "Attribution",
"description": "engine#90 - schema 1.9 (additive). Content attribution for the set: who wrote it, and the bounded chain it was derived through. PERSONAL DATA: the name travels with the set when it is shared; a consumer app must point that out before it becomes visible (the contributed_by opt-in pattern). Distinct from and never merged with: 'book' (source material), 'ai_validation' (review provenance in free-form metadata), repo-level 'metadata.author' (repo operator) and the lesson-level 'contributed_by' (per-lesson credit)."
},
"review_status": {
"anyOf": [
{
"type": "string",
"enum": [
"authored",
"generated",
"reviewed"
]
},
{
"type": "null"
}
],
"default": null,
"title": "Review Status",
"description": "engine#94 - schema 1.9 (additive). Three-state review standing derived from ORIGIN, because origin is what makes a set review-worthy: 'authored' = hand-written by a speaker/domain expert, no review required; 'generated' = machine-generated (AI/book/analysis), native-speaker or expert review PENDING; 'reviewed' = machine-generated and reviewed. Absent means 'authored' (legacy hand-written content). Consumers derive 'advertisable as reviewed' as status != 'generated'. Distinct from 'visibility' (display hint, never a quality statement) and from 'ai_validation' (AI check provenance)."
}
},
"required": [
Expand All @@ -181,17 +246,17 @@
},
"ContentSetAsset": {
"additionalProperties": false,
"description": "One bundled binary asset (image, audio) declared in the\nset manifest (Phase 54 / v1.37.0).\n\nThe declaration drives:\n - the downloader (fetch_asset per ``path`` alongside the\n lesson JSON)\n - the cache writer (store under ``assets/{path}``)\n - the size validator (reject ``size_kb > MAX_ASSET_SIZE_KB``)\n\nOptional everywhere \u2014 sets without any assets simply\nomit the ``assets`` list. The current PictureChoice\ncomponent falls back to text-only when the resolver\ncan't produce a blob URL, so authored content without\nassets stays playable.",
"description": "One bundled binary asset (image, audio) declared in the\nset manifest (Phase 54 / v1.37.0).\n\nThe declaration drives:\n - the downloader (fetch_asset per ``path`` alongside the\n lesson JSON)\n - the cache writer (store under ``assets/{path}``)\n - the size validator (reject ``size_kb > MAX_ASSET_SIZE_KB``)\n\nOptional everywhere sets without any assets simply\nomit the ``assets`` list. The current PictureChoice\ncomponent falls back to text-only when the resolver\ncan't produce a blob URL, so authored content without\nassets stays playable.",
"properties": {
"path": {
"description": "Relative path inside the set's ``assets/`` directory. Example: ``img/sunrise.png`` resolves to ``{cache_root}/.../assets/img/sunrise.png``. No leading slash, no ``..`` segments \u2014 the path is appended to a Path() and any upward navigation would escape the cache isolation.",
"description": "Relative path inside the set's ``assets/`` directory. Example: ``img/sunrise.png`` resolves to ``{cache_root}/.../assets/img/sunrise.png``. No leading slash, no ``..`` segments the path is appended to a Path() and any upward navigation would escape the cache isolation.",
"maxLength": 300,
"minLength": 1,
"title": "Path",
"type": "string"
},
"size_kb": {
"description": "Declared file size in KiB (used by the validator + the downloader's progress reporting). The downloader rejects assets whose actual byte length exceeds ``size_kb * 1024`` by more than 10 percent \u2014 keeps content authors honest.",
"description": "Declared file size in KiB (used by the validator + the downloader's progress reporting). The downloader rejects assets whose actual byte length exceeds ``size_kb * 1024`` by more than 10 percent keeps content authors honest.",
"maximum": 500,
"minimum": 1,
"title": "Size Kb",
Expand All @@ -206,7 +271,7 @@
"type": "object"
},
"ContentSetBook": {
"description": "#769 \u2014 optional set-level book block (manifest ``sets[].book``).\n\nSurfaced to the lesson's \"Vertiefe das Thema\" section as the first\nmedia item. ``extra=\"ignore\"`` tolerates future fields (e.g. ``isbn``,\n``year``) the media card doesn't consume.",
"description": "#769 optional set-level book block (manifest ``sets[].book``).\n\nSurfaced to the lesson's \"Vertiefe das Thema\" section as the first\nmedia item. ``extra=\"ignore\"`` tolerates future fields (e.g. ``isbn``,\n``year``) the media card doesn't consume.",
"properties": {
"asin": {
"anyOf": [
Expand Down Expand Up @@ -283,7 +348,7 @@
"metadata": {
"additionalProperties": true,
"default": {},
"description": "Free-form repo-level metadata (license, author, homepage URL, contact). The loader does not interpret these fields \u2014 they surface as-is in the Set Browser's 'About this source' panel.",
"description": "Free-form repo-level metadata (license, author, homepage URL, contact). The loader does not interpret these fields they surface as-is in the Set Browser's 'About this source' panel.",
"title": "Metadata",
"type": "object"
},
Expand Down Expand Up @@ -315,5 +380,5 @@
],
"title": "ContentManifest",
"type": "object",
"x-schema-version": "1.8"
"x-schema-version": "1.9"
}
2 changes: 1 addition & 1 deletion schema/engine-version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.14.0
0.16.1
Loading
Loading