Skip to content

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

GET https://training.gov.au/api/training/{code}/releases/{releaseNumber}/unitgrid

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 to false in unitgrid ingestion; if/when we need the fallback flag, the LLM parser extracts it.
  • rules[1].count — taken from qualifications.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, not pool.length.

Running the ingestion

Full corpus (one-time)

CF_API_TOKEN=... node tools/parse-packaging-rules.mjs --unitgrid
  • 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-fields enrich 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)

CF_API_TOKEN=... node tools/parse-packaging-rules.mjs --unitgrid --canary

Runs against BSB40920, CHC50121, SIT40521 only. Writes to D1 (the canary output IS ground truth — no dry-run needed in unitgrid mode).

Single qual

CF_API_TOKEN=... node tools/parse-packaging-rules.mjs --unitgrid --qual BSB40920

Force re-run

CF_API_TOKEN=... node tools/parse-packaging-rules.mjs --unitgrid --force

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:

  1. Class 1 — deleted quals. Untestable from this population, and NOT refuted. It keys on rtopacks-db.qualifications.status, a third field distinct from release.currency and from the grid's own usageRecommendation. Stated as untested rather than dismissed.
  2. Class 2 — superseded quals whose release detail TGA retired. Tested against 4,209 replaced releases: 0 non-200.
  3. 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_number first. If the row has NULL, the script skips it with a no release_number in qualifications row log line. Re-run tools/tga-enrich-quals.mjs --version-fields to 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-fields rerun. The script handles these as failed and they can be recovered by a second run because the default filter picks up unprocessed rows.
  • isEssential: null rows 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.