DEV Community

Rostery Team
Rostery Team

Posted on

Routing NDIS Medication Events as Data: Reportable-Incident Rules, Awareness Clocks and a Competency Gate on the Roster

Originally based on our article Medication management and NDIS compliance on rostery.com.au. This version is rewritten for developers and operations staff who maintain incident and medication workflows for a disability provider. It also corrects how the original describes when a medication error becomes reportable, covered in the first section.

Most medication software stops at the dose. A worker records what was given, the chart updates, a report shows adherence. That part is well understood, and we wrote about the record structure itself in an earlier post on designing a medication administration record for NDIS support.

This post is about what happens next: the moment a dose goes wrong, or a PRN is given for the wrong reason, or a worker who has never been trained on a participant's PEG tube is rostered on to give their evening medication. Those are routing problems. Something has to decide whether the event is reportable to the NDIS Quality and Safeguards Commission, which clock applies, when the clock started, and whether the roster should have allowed the shift at all.

We'll build a small Python model for each of those decisions. It's deliberately plain: dataclasses, a few enums, pure functions you can test.

General information only, not legal or clinical advice. Rules were read on the NDIS Commission's website on 9 November 2026. Check the current Commission guidance before you rely on any of this in production.

The rule most systems get wrong

There's no "medication error" category in the NDIS reportable incidents list. A lot of policy templates, and our own original article, suggest one exists: an error that "results in, or is likely to result in, serious harm" is reportable. That's not how the Commission frames it.

The reportable incidents are defined by what happened to the person, not by the kind of mistake that caused it. A medication event becomes reportable only when it fits one of the listed incidents. For medication, three routes come up again and again:

  • Serious injury. The Commission's detailed guidance treats an injury needing hospital treatment, including a presentation to an emergency department, as a serious injury. A wrong dose that sends someone to ED is reportable on this route.
  • Neglect. A reckless act or a failure to act that results in, or could result in, death or significant harm, including failing to get someone medical care. A missed dose of a critical medication, left without follow-up, may sit here.
  • Unauthorised use of a restrictive practice. A PRN given mainly to change someone's behaviour, outside an authorisation and a behaviour support plan, is a chemical restraint used without authorisation.

Everything else, including most errors and every near miss, is internal. It still has to be recorded, reviewed and acted on, because the Practice Standards expect an incident management system that covers all incidents, not just the reportable ones.

So the first design decision is to keep two things apart: the medication event (what went wrong with the medication) and the incident route (what the Commission needs to hear about). One event can produce no routes, one route, or occasionally two.

Decision flow for a medication event: record the event, then ask three questions in order: did it lead to hospital or emergency department treatment, was there a failure to act with potential for significant harm, was a PRN given mainly to influence behaviour without authorisation; each yes adds a reportable route, and every event stays in the internal register

Modelling the event

Start with the facts a worker can actually supply at the end of a shift. Avoid fields like is_reportable on the worker's form. That's a judgement for the office, and the form should collect the inputs to that judgement instead.

from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum

class EventKind(Enum):
    WRONG_DOSE = "wrong_dose"
    WRONG_TIME = "wrong_time"
    MISSED = "missed"
    WRONG_PERSON = "wrong_person"
    PRN_GIVEN = "prn_given"
    NEAR_MISS = "near_miss"

@dataclass
class MedicationEvent:
    participant_id: str
    medication_id: str
    kind: EventKind
    occurred_at: datetime
    recorded_by: str
    description: str
    # Facts that drive routing. Unknown is allowed: None means "not yet established".
    hospital_or_ed: bool | None = None
    harm_to_person: bool | None = None
    failure_to_act_risk: bool | None = None      # office judgement after review
    prn_primary_purpose_behaviour: bool | None = None
    prn_covered_by_authorisation: bool | None = None
    key_personnel_aware_at: datetime | None = None
    notes: list[str] = field(default_factory=list)
Enter fullscreen mode Exit fullscreen mode

Two choices in there are worth defending.

Tri-state booleans. None is not False. A worker writing up a wrong dose at 9pm doesn't know yet whether the person will end up in ED. If you default unknowns to False, your router will quietly decide "not reportable" for every fresh event. Treat None as "needs review" and surface it.

A separate awareness timestamp. The Commission's clocks run from when your key personnel become aware of the incident, not from when it happened. Those can be hours apart, or days apart if a worker sat on it. Store both. occurred_at belongs to the event; key_personnel_aware_at belongs to the organisation's response, and it should be set by whoever escalates, not inferred.

Routing as a pure function

The router takes an event and returns zero or more routes. No database calls, no side effects, so it can be tested exhaustively.

class Route(Enum):
    SERIOUS_INJURY = "serious_injury"
    NEGLECT = "neglect"
    UNAUTHORISED_RESTRICTIVE_PRACTICE = "urp_chemical_restraint"

@dataclass
class RoutingResult:
    routes: list[Route]
    needs_review: list[str]

def route(e: MedicationEvent) -> RoutingResult:
    routes, review = [], []

    if e.hospital_or_ed is None:
        review.append("Did the person need hospital or ED treatment?")
    elif e.hospital_or_ed:
        routes.append(Route.SERIOUS_INJURY)

    if e.kind in (EventKind.MISSED, EventKind.WRONG_TIME, EventKind.WRONG_DOSE):
        if e.failure_to_act_risk is None:
            review.append("Could the failure to act have caused significant harm?")
        elif e.failure_to_act_risk:
            routes.append(Route.NEGLECT)

    if e.kind is EventKind.PRN_GIVEN:
        if e.prn_primary_purpose_behaviour is None:
            review.append("Was the PRN given mainly to influence behaviour?")
        elif e.prn_primary_purpose_behaviour and not e.prn_covered_by_authorisation:
            routes.append(Route.UNAUTHORISED_RESTRICTIVE_PRACTICE)

    return RoutingResult(routes, review)
Enter fullscreen mode Exit fullscreen mode

The neglect branch is the one you'll argue about with your clinical lead, and that's fine. Making failure_to_act_risk an explicit office judgement, recorded against a named reviewer, is better than burying a rule like "any missed anticonvulsant is neglect" in code. The code shouldn't pretend to make clinical calls. It should make sure someone does, and that the answer is written down.

The chemical restraint branch needs one more piece of context. Under the restrictive practices rules, chemical restraint is medication used for the primary purpose of influencing behaviour. Medication prescribed by a medical practitioner to treat a diagnosed mental disorder, physical illness or physical condition is excluded. That's why the PRN record needs a reason field with real options, not free text, and why prn_covered_by_authorisation should be looked up from the participant's authorisation records rather than ticked by the worker.

The clocks

Once you have routes, deadlines follow. The Commission's timeframes, read on 9 November 2026, with the 5-day report counted from awareness too:

Route First report 5-day report Final report
Serious injury 24-hour report Within 5 business days Within 60 business days, if the Commission asks for one
Neglect 24-hour report Within 5 business days Within 60 business days, if the Commission asks for one
Unauthorised restrictive practice, no harm None: the 5-day report is the only form Within 5 business days Within 60 business days, if the Commission asks for one
Unauthorised restrictive practice, person harmed 24-hour report Within 5 business days Within 60 business days, if the Commission asks for one

All of these count from when the provider became aware, not from when the event happened.

The 24-hour clock is wall-clock time. The others are business days, so you need a calendar. Keep public holidays in a table, not in code, and treat weekends and public holidays as non-business days.

from datetime import date, timedelta

def add_business_days(start: datetime, n: int, holidays: set[date]) -> datetime:
    d = start
    added = 0
    while added < n:
        d += timedelta(days=1)
        if d.weekday() < 5 and d.date() not in holidays:
            added += 1
    return d

def first_deadline(r: Route, e: MedicationEvent, holidays: set[date]) -> datetime | None:
    aware = e.key_personnel_aware_at
    if aware is None:
        return None  # escalate: nobody senior knows yet
    if r is Route.UNAUTHORISED_RESTRICTIVE_PRACTICE and not e.harm_to_person:
        return add_business_days(aware, 5, holidays)
    return aware + timedelta(hours=24)

def five_day_report_due(e: MedicationEvent, holidays: set[date]) -> datetime | None:
    # Applies to every route; for a URP with no harm it is the only report.
    if e.key_personnel_aware_at is None:
        return None
    return add_business_days(e.key_personnel_aware_at, 5, holidays)
Enter fullscreen mode Exit fullscreen mode

Notice the URP branch checks harm_to_person, which is a different question from hospital_or_ed. A person can be harmed without going to hospital. If harm is None, the safe default is the shorter clock, and the code above gets that right only because not None is True. Make it explicit in your own version; implicit truthiness is exactly the kind of thing that breaks quietly in a refactor.

NDIS reportable incident clocks counted from when the provider became aware: a 24-hour report for serious injury, neglect and an unauthorised restrictive practice that harmed the person; a 5-day report within 5 business days for every route, which is the only form for an unauthorised restrictive practice without harm; and a final report within 60 business days if the Commission asks for one

A few fixtures to pin it down:

def test_prn_for_behaviour_without_authorisation_routes_to_urp():
    e = make_event(kind=EventKind.PRN_GIVEN, prn_primary_purpose_behaviour=True,
                   prn_covered_by_authorisation=False, hospital_or_ed=False)
    assert route(e).routes == [Route.UNAUTHORISED_RESTRICTIVE_PRACTICE]

def test_wrong_dose_with_ed_visit_is_serious_injury_24h():
    e = make_event(kind=EventKind.WRONG_DOSE, hospital_or_ed=True,
                   failure_to_act_risk=False, key_personnel_aware_at=FRI_6PM)
    assert first_deadline(Route.SERIOUS_INJURY, e, HOLIDAYS) == SAT_6PM

def test_near_miss_has_no_route_but_stays_in_register():
    e = make_event(kind=EventKind.NEAR_MISS, hospital_or_ed=False)
    assert route(e).routes == []
Enter fullscreen mode Exit fullscreen mode

That last test matters. A near miss with no route is still a row in the incident register. Never let "not reportable" mean "not recorded".

The competency gate: stopping the shift before the event

The cheapest medication incident is the one that never gets rostered. Two layers of training apply.

First, the core Practice Standard on management of medication expects medication to be accessed only by appropriately trained workers. That's a baseline for anyone who handles medicines.

Second, some medication tasks are high intensity supports in their own right. The High Intensity Daily Personal Activities module covers subcutaneous injections, enteral feeding (which includes medication through a tube) and complex bowel care (which can involve suppositories and enemas). Training for these has to be specific to the participant, and subcutaneous injections also need written or phone orders from the prescriber. Giving a tablet or applying a cream isn't a high intensity support under that module. Who may do it depends on state or territory rules and your own policy.

In data terms, that's a join between three tables: the participant's support needs, the qualifications those needs call for, and each worker's verified, unexpired qualifications. Participant-specific training is a qualification scoped to one participant.

@dataclass(frozen=True)
class Qualification:
    code: str                      # e.g. "MED_CORE", "SUBCUT_INJ"
    participant_id: str | None     # None = general; set = specific to one person
    expires: date | None

def missing_for_shift(worker_quals: list[Qualification], required: set[str],
                      participant_id: str, on: date) -> set[str]:
    have = {q.code for q in worker_quals
            if (q.expires is None or q.expires >= on)
            and (q.participant_id in (None, participant_id))}
    return required - have
Enter fullscreen mode Exit fullscreen mode

Then decide what the scheduler does with a non-empty result. Blocking outright is tempting, but rosters have emergencies, and a hard block often leads to staff finding a workaround outside the system. A clear warning on save, recorded against the person who overrode it, is usually more honest. Whatever you choose, make the medication shift visibly different from an ordinary one so the coordinator can't miss it.

Competency gate on a medication shift: the participant's support needs map to required qualifications such as core medication training and subcutaneous injection training specific to that participant; the worker's verified, unexpired qualifications are compared, and any gap shows as a warning on the shift before it is published

How it fits together in Rostery

We'll be specific about what our product does here, because routing logic is easy to over-claim.

In Rostery, the eMAR medication module records each dose with an outcome (given, refused, not available, held, or PRN not required), the carer's typed signature, a witness and running balance for S8 medicines, and an effectiveness review. Workers report incidents from the app with a type, priority and what happened, and the office manages each one in the incident register, where switches record whether it's NDIS reportable and whether it has been reported. Rostery doesn't lodge anything in the Commission's portal; that step stays with your key personnel.

On the roster side, SmartMatch links qualifications to participant support needs and ranks workers by how well their verified, unexpired qualifications cover them, and the scheduler warns when a worker is missing a qualification the participant's needs call for. The routing function in this post isn't a Rostery feature. It's a pattern you can apply in any system, including ours, by keeping the reportability decision with a named reviewer.

A short checklist for your own build

  • Keep medication events and incident routes as separate records.
  • Collect facts on the worker's form; make reportability an office decision.
  • Use tri-state answers and show unknowns as review tasks.
  • Store when key personnel became aware, separately from when it happened.
  • Count business days from a holiday table, and test the URP harm branch.
  • Record every event, including near misses, whether or not it's reportable.
  • Treat participant-specific training as a qualification scoped to one person.

If you want the floor-level version of this for team leaders, we wrote up what a shift supervisor checks before a medication round on our Blogger site.

Sources: NDIS Quality and Safeguards Commission, Reportable incidents and Detailed guidance: Reportable incidents; NDIS Practice Standards, Core Module (Provision of supports environment: Management of medication) and High Intensity Daily Personal Activities module; NDIS Commission, High Intensity Support Skills Descriptors; NDIS (Restrictive Practices and Behaviour Support) Rules 2018, section 6. Rostery feature pages read 9 November 2026.

Written by the Rostery team in Sydney. Rostery makes rostering, care and billing software for NDIS and aged care providers.

Top comments (0)