TGA Unit Grid Endpoint¶
First discovered: 2026-04-16 (STUDIO-PACKAGING-PARSE-01 reframe).
Canonical consumer: tools/parse-packaging-rules.mjs --unitgrid.
Storage target: qualification_packaging_rules in rtopacks-db, parser_version = 'unitgrid-v1'. [2026-09-15, NAME-RETIRE-01, R-NR-2: rto-nrt-db (rtopacks-db, prototype account) is frozen — the old data model; the new build's stores of record are the Mac mirror (store-register.md) and their Product-account promotions.]
Related docs: source-of-truth-connectivity.md, tga-api-field-inventory.md.
Why this exists¶
TGA publishes the authoritative core/elective split for every qualification release via a dedicated endpoint. We didn't know about it until the STUDIO-PACKAGING-PARSE-01 swagger audit. This page is the canonical reference for the endpoint, its contract, and the ingestion pattern — so no future Claude burns hours rediscovering it.
⚠ Corrected 2026-09-04. This page used to say the grid removes the need to parse the packaging-rules prose. It does not. Measured over 12,902 releases: the grid gives a full core/elective split for 51.79% of them, gives no classification for 24.76%, is absent (200 + []) for 20.41%, and is mixed for 3.04% — and no fully classified grid exists earlier than the 2010s. On the mixed grids, 99.5% of the units the grid declines to classify are already named by the packaging table (1,636 of 1,645 across ten read by hand). tga-data-model.md §2.2 is right as written: the packaging table is the source and the grid is the corroboration.
The endpoint¶
Path params:
| Param | Type | Example | Notes |
|---|---|---|---|
code |
string | BSB40920 |
Qualification code. Case-sensitive in the URL but TGA accepts either. |
releaseNumber |
string | 1 |
Release number for that qual. Read from qualifications.release_number in rtopacks-db (populated by tools/tga-enrich-quals.mjs --version-fields). |
Auth: None. Public endpoint, no headers required.
Query params: None currently used. The swagger spec lists api-version with default 1.0, unused in practice.
Response: JSON array of unit objects, one per unit in the qualification's unit grid (both core and elective). No wrapper — the array itself is the body.
Response shape¶
Per swagger: schema UnitGridUsage, array of objects.
[
{
"code": "BSBPMG420",
"title": "Apply project scope management techniques",
"isEssential": true,
"isEssentialLabel": "Core",
"usageRecommendation": "current",
"usageRecommendationLabel": "Current",
"hasPreRequisites": false,
"links": [
{ "rel": "training-component", "href": "https://training.gov.au/api/training/bsbpmg420" }
]
},
{
"code": "BSBCRT411",
"title": "Apply critical thinking to work practices",
"isEssential": false,
"isEssentialLabel": "Elective",
"usageRecommendation": "current",
"usageRecommendationLabel": "Current",
"hasPreRequisites": false,
"links": [ ... ]
}
]
Key field: isEssential.
true→ unit is core (required for the qual).false→ unit is elective (part of the pool the RTO picks from).null→ skill set (not a qualification); filter these out upstream.
isEssentialLabel is the same information as a display string.
Field mapping to qualification_packaging_rules¶
The ingestion script transforms the flat array into the table's schema:
TGA unitgrid response → qualification_packaging_rules row
──────────────────────────────────────────────────────────────────────
units where isEssential=true → groups[0] (id="core", label="Core")
units where isEssential=false → groups[1] (id="elective_pool", label="Electives")
COUNT(isEssential=true) → rules[0] (type="fixed", group_id="core", count=N)
qualifications.elective_units_ → rules[1] (type="from_group", group_id="elective_pool",
count count=M, open_corpus_fallback=false)
qualifications.total_units_ → total_required
required
raw response JSON → raw_input
"high" → confidence
What we lose (deliberately): Group A / Group B / Group C / ... sub-structure. TGA does NOT publish this in any structured endpoint — it only exists in the prose HTML of content bundle item 0116 (packaging_rules). The script deliberately collapses all electives into a single elective_pool. For the canvas's first ship, a flat pool of real units per qual is the right foundation. When per-group picker filtering becomes a product requirement, turn on the LLM parser path (tools/parse-packaging-rules.mjs without --unitgrid) — canary already passed three quals at high confidence.
What we set conservatively:
open_corpus_fallback: false— the unitgrid endpoint doesn't tell us whether a qual allows "any current endorsed Training Package" substitution. It's only in the prose. Set tofalsein unitgrid ingestion; if/when we need the fallback flag, the LLM parser extracts it.rules[1].count— taken fromqualifications.elective_units_count(populated by the enrich pass). The unit grid lists more electives than the user actually has to pick — that's the whole point of a pool — so we use the declared elective count, notpool.length.
Running the ingestion¶
Full corpus (one-time)¶
- Default filter:
WHERE release_number IS NOT NULL AND qual_code NOT IN (SELECT qual_code FROM qualification_packaging_rules WHERE parser_version = 'unitgrid-v1'). - 5 concurrent TGA fetches with 100ms stagger — same pattern as the
--version-fieldsenrich pass. Effective ~50 req/sec against TGA, tolerated cleanly in production runs. - ~30 minute wall clock for ~8007 quals.
- Resumable: a crashed run picks up on the next invocation; only unprocessed rows are targeted.
Canary (three hand-verified quals)¶
Runs against BSB40920, CHC50121, SIT40521 only. Writes to D1 (the canary output IS ground truth — no dry-run needed in unitgrid mode).
Single qual¶
Force re-run¶
Reprocesses every qual regardless of whether it already has a unitgrid-v1 row. Use this when TGA has updated a qual's unit grid and we want a fresh pass, or when the transform logic has changed in a compatible way.
Absence on this endpoint (rewritten 2026-09-04 on a full walk)¶
⚠ The "404 handling" this section described did not occur once in 12,902 requests. Lane Q walked
every (code, release_number) pair in the register on 2026-09-03: 12,902 requests, status
{200: 12,902}, zero 404s, zero retries, no backoff armed.
Absence is 200 + [], not 404. 2,633 of 12,902 bodies (20.41%) are the two-byte body [],
sharing one digest between them. A loader that counts 404s to find absence measures nothing, and
ingests 2,633 qualifications as having no units without ever erroring.
The three classes as they actually stand:
- Class 1 — deleted quals. Untestable from this population, and NOT refuted. It keys on
rtopacks-db.qualifications.status, a third field distinct fromrelease.currencyand from the grid's ownusageRecommendation. Stated as untested rather than dismissed. - Class 2 — superseded quals whose release detail TGA retired. Tested against 4,209
replacedreleases: 0 non-200. - Class 3 — quals with no unit grid at all. Real, and it presents as
200+[]— the 2,633.
And the instrument-origin reading is recorded beside the TGA-changed one, neither asserted: the
April walk keyed on rtopacks-db's own release_number, and a stale or NULL value there builds a
URL that 404s. The 404s may always have been ours.
Refresh cadence¶
Initial ingestion: one-time, full corpus (including superseded) — done once in session 62, 2026-04-16.
Ongoing — and it is TIME-BASED, not event-triggered (corrected 2026-09-04). Grid bodies churn on
unit currency, not on qualification releases. Measured across 92 August/today grid pairs over six
days: 0 rows added, 0 removed, 0 isEssential changed, 0 other fields changed — and 274
usageRecommendation changes, every one current → superseded, 0 backward, 0 reaching deleted.
A refresh keyed on "this qualification has a new release" would have caught none of the 274. The grid's membership is stable; what moves inside it is the register's own currency, on its own clock. So "current" for the unit grid means a time-based re-walk.
Recommended cadence:
| Trigger | Command | Frequency |
|---|---|---|
Post-tga-sync new releases |
--unitgrid --force --qual <code> per changed code |
Per-release basis, integrated into the sync chain |
| Monthly audit of current quals | --unitgrid --force filtered by status='Current' |
Monthly, run manually |
| Incident re-ingestion (bad row found) | --unitgrid --force --qual <code> |
As needed |
The --force combined with a single --qual targets one row. Combined with no filter, it rewrites the whole corpus — only do this if the transform logic has changed.
Known gotchas¶
- Release number must be in
qualifications.release_numberfirst. If the row hasNULL, the script skips it with ano release_number in qualifications rowlog line. Re-runtools/tga-enrich-quals.mjs --version-fieldsto populate release numbers if needed. - Cold-start bursts on large runs. The first batch of a full run against TGA sometimes sees HTTP failures from rate limiting — same burst we saw in the
--version-fieldsrerun. The script handles these asfailedand they can be recovered by a second run because the default filter picks up unprocessed rows. isEssential: nullrows come from skill sets. The transform ignores them so they don't pollute either group.
What the endpoint does NOT give you¶
See ops/tga-api-field-inventory.md for the full picture, but at a glance:
- ❌ Group A / Group B / Group C sub-structure within electives.
- ❌ Selection rules (
choose 3 from Group A, 3 from A+B union, with open corpus fallback). - ❌ Open corpus fallback flag or constraint.
- ❌ Pre-requisite chains between units (use
/api/training/{unitCode}for that).
All of the above live in the packaging rules prose HTML (content bundle item 0116). The LLM parser in tools/parse-packaging-rules.mjs (no --unitgrid flag) is built and canary-verified for exactly this — turn it on when per-group picker filtering or open-corpus substitution becomes a product requirement.