Getting started
Companies and complexes: the tree and its schools' data
Scopes answer «which data»; the previous chapter answers «who is sitting there». A third question reaches neither: what kind of place is your app running in, and what sits above and beneath it.
A tenant is not always a school. An education company owns
complexes, a complex holds schools — and the same app may be installed
at any of the three levels. On a school your app shows that school's pupils; at
the company's head office it has no pupils of its own, and its job is to move
across the schools beneath it. Before these endpoints /whoami gave an app a
numeric tenant id and nothing else: a head-office app could not name its
schools, and a school app could not tell it belonged to a complex at all.
In short — can a complex's app read its schools' data? Yes, straight from the mini-app. Name the schools on any data read:
?entities=<ulid>,<ulid>(an envelope keyed by school) or?entity=<ulid>(one school's answer, as is). Three things are needed: your app holdsentity.hierarchy.read, the data scope is granted on the complex or on the school — either is enough — and the person using the app holdstenants.children.view_membersin the complex. The details are in «Reading the schools' data from a complex» below, and the full Dart example in thenativemode chapter.
The tree has no fixed depth
This is the first thing apps get wrong. A school may sit under a complex under a company; under a complex that is itself the root with no company above; directly under a company with no complex between; or alone under nobody. One company may hold both shapes at once — a direct school and one inside a complex.
company ─┬─ complex ─┬─ school A
│ └─ school B
└─ school C ← direct, no complex
standalone complex ─── school D ← no company above
school E ← nobody above
So never write «the second ancestor is the company». Read the chain as it
comes and check type.
Where am I? — GetCurrentEntity (no scope)
use App\Services\PartnerApi\Entity\Hierarchy\GetCurrentEntity;
$here = app(GetCurrentEntity::class)();
$here->id; // the entity's ULID — the same `entityId` ViewerDto reports
$here->type; // school | complex | education_company | university | …
$here->isContainer; // may this type hold other entities?
$here->parentId; // ULID of the entity directly above, or null
$here->students; // held by THIS entity itself — never the tree's total
Unscoped on purpose, beside /whoami and the clock and for the same
reason: the entity's own name and type are the frame your data is read in, not
data a grant protects. The parent's name and the rest of the tree belong to
other tenants and sit behind the scope below.
Start here. type decides which screen your app draws: isContainer true
⇒ the head-office screen (a list of entities); false ⇒ the school screen
(pupils).
The one scope: entity.hierarchy.read
Unlocks the tree reads below, up and down, and means one thing: «this app
may see the shape of the tree it is in» — who is beneath it, each one's
profile, and who is above. A school that is a company's customer did not agree
to be enumerated by every app the company installs on itself — so the entity's
administrator grants this explicitly. There is no .write: the tree is drawn
by the platform and the company's administrators, and an app that could
re-parent a school is an app that could move pupils between customers.
It is also a condition for reading the children's data (below): an app that was not allowed to see the tree does not read its schools, whatever ULIDs it holds.
Down: who is beneath me? — ListChildEntities
use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;
$direct = app(ListChildEntities::class)(); // next level only
$all = app(ListChildEntities::class)(descendants: true); // every level
foreach ($all as $entity) {
$entity->name; // "Elite School"
$entity->type; // school
$entity->parentId; // its parent's ULID — rebuild the tree from the flat list
$entity->students; // its own pupils
$entity->children; // how many entities it holds (a complex)
$entity->installed; // whether your app has its own installation there
$entity->access; // ['installed' => …, 'scopes' => [...]] ← what can be read there
}
installed and access are what make the list actionable.
installed— whether your app has its own installation on that entity. That is still the only way to write into it, with the scopes its administrators granted.access.scopes— the read scopes your app reads that entity with: what it granted your installation there, if any, plus the read grant your own entity gave, carried down. Either is enough — so a school that never installed your app is read with the complex's grant.
The list also says whether the person using the app right now may read the
children: can_read_children on the /entity/children response, and
app(CanReadChildEntities::class)() in embedded code. Read it before you
draw a «download the schools' data» button, and do not draw it when false.
(Installing on many children at once is done by the company's administrator in okta-web: Child entities → Install app — each child gets its own installation and its own grant.)
Up: whom do I belong to? — GetEntityAncestors
use App\Services\PartnerApi\Entity\Hierarchy\GetEntityAncestors;
$chain = app(GetEntityAncestors::class)(); // nearest first; empty at the root
// a school under a complex under a company:
$chain[0]->type; // complex ← the complex
$chain[1]->type; // education_company ← the company
// a school directly under a company:
$chain[0]->type; // education_company — and there is no [1]
// a school alone:
$chain === [];
This decides real things in an app: whose letterhead goes on a report, whether a fee is set here or inherited from above, whether a notice came from the school or from head office. Search the chain for the type you want rather than relying on its position:
$company = collect($chain)->firstWhere('type', 'education_company'); // or null
$complex = collect($chain)->firstWhere('type', 'complex'); // or null
One school's profile — GET /entity/children/{id}
The list is a map; this is what you draw on a school's card:
{
"id": "01HN…A", "name": "Elite School", "type": "school", "parent_id": "01HN…X",
"status": "active", "students": 412, "employees": 38, "children": 0,
"grades": 6, "sections": 18,
"logo": "/api/apps/entity/children/01HN…A/logo",
"phone": "0112345678", "email": "info@…", "website": null, "address": "Al Malqa, Riyadh",
"country": { "code": "SA", "name": "Saudi Arabia" },
"timezone": "Asia/Riyadh",
"stage": { "id": 3, "country_id": 1, "name_ar": "الابتدائية", "name_en": "Primary", "order": 1, "is_active": true },
"installed": false,
"access": { "installed": false, "scopes": ["education.students.read", "entity.hierarchy.read"] },
"can_read_children": true
}
- The logo is a path, not a URL, like a pupil's photograph: a mini-app
cannot fetch a URL itself, so show it with
Okta.remoteImage(school['logo'], 48).nullwhen there is no logo. country.codeis the code/countriesanswers with, andstageis the very object/countries/education-levelslists — match by id, not name.- Counts are the school's own, like everywhere on this surface — never a tree's sum.
- A school not beneath you, and a ULID that names nothing: one
404—entity_not_found.
Reading the schools' data from a complex — ?entities= and ?entity=
Your app is open in a complex, and the pupils are in its schools. The data routes themselves answer for the schools beneath when you name them — no server code, straight from the mini-app:
final r = await Okta.getQuery('/api/apps/education/students', {
'entities': '$schoolA,$schoolB', // up to 20 schools per call
'per_page': '100',
});
The answer is wrapped by school, and each key holds exactly the answer the route gives that school — read it with the same code you read your own entity's answer with:
{
"entities": {
"01HN…A": { "data": [ … ], "total": 412, "per_page": 100, "current_page": 1, "last_page": 5 },
"01HN…B": { "data": [ … ], "total": 88, "per_page": 100, "current_page": 1, "last_page": 1 },
"01HN…C": { "error": "missing_scope", "status": 403, "scope": "education.guardians.read", "message": "…" }
}
}
Which grant reads a school? Either of two, and either is enough: what the school granted your installation there, or the read grant your entity (the complex or company) gave, carried down. So a school that never installed your app, removed it, or did not grant the scope, is read as long as the complex granted it. Two limits:
- Only scopes ending
.readtravel down. Writing into a school still takes that school's own grant:ActInChildEntityfor a person operating the complex's managed copy (below),RunAsChildEntityin a job, or that school's own token for your server. - They travel for a person. Whoever is using the app must hold the
unified-registry permission (
tenants.children.view_members) in the complex — else403 children_not_readable. Your server with an installation token never reads this way (403 entities_need_a_person): it reads each school with that school's own token, as before.
How the answer behaves:
- Pagination is per school:
pageandper_pageapply to each key. For a school with pages left, ask it alone:?entities=01HN…A&page=2. - A refused school is one key, not the response: its key carries
errorandstatus(andscopewhen a scope is missing), the rest arrives, and the response is200. entity_not_foundfor a school not beneath you and for a ULID that names nothing — the same answer on purpose, so the envelope cannot be used to learn what exists.- Your own entity may be named in the list, and its data arrives under its key.
- A filter that belongs to one school (
section_id, say) is sent as-is to every school, and the others answer an empty page, not an error.
?entity=<ulid> for one school: the answer is that school's answer as
is — no envelope, same status and headers. A pupil's photograph is only read
this way, because a file cannot sit inside an envelope:
Okta.remoteImage('/api/apps/education/students/$id/photo?entity=$school', 64);
Refused by name, never ignored (ignoring it would answer with the complex's own data looking like success):
| Code | Status | When |
|---|---|---|
entities_read_only |
422 | any write — this is a download |
entities_not_supported |
422 | a route that answers your own entity: whoami, the clock, the tree, payments, notifications — or entities on a file route |
too_many_entities |
422 | more than 20 |
entity_and_entities / invalid_entities |
422 | both at once, or an empty list |
missing_scope (entity.hierarchy.read) |
403 | your app does not hold the tree scope |
children_not_readable |
403 | the person does not hold tenants.children.view_members, or came in through a narrowed door (a guardian's delegate) |
entities_need_a_person |
403 | an installation token (a server) or a device with no person |
It works on every read of an entity's data: education/* (pupils, photos,
face templates, guardians, subjects, curriculum, grades, sections, years,
terms), employees/directory, tool-keys/* and reports/*. No
minContract bump: the parameter is part of the path, and
Okta.get/Okta.getQuery carry it on every host.
Acting inside a child — RunAsChildEntity (embedded only)
The embedded counterpart of ?entities=: it steps into a child and runs your
code there, so every PartnerApi service inside behaves as if your app were
installed on that school. The same rule: inside the school your app holds
what the school granted its installation there, plus the read grant from
your entity carried down when the one asking is a person holding
tenants.children.view_members. Writes take the school's own grant only.
use App\Services\PartnerApi\Entity\Hierarchy\ListChildEntities;
use App\Services\PartnerApi\Entity\Hierarchy\RunAsChildEntity;
use App\Services\PartnerApi\Education\Students\ListStudents;
$children = app(ListChildEntities::class)(descendants: true);
foreach ($children as $school) {
if ($school->type !== 'school' || ! in_array('education.students.read', $school->access['scopes'], true)) {
continue; // not a school, or no grant reads its pupils
}
$students = app(RunAsChildEntity::class)($school->id, function () {
// In here you ARE "an app on this school": Tenant::current() is the
// school, the scopes are its own grant plus your entity's reads.
return app(ListStudents::class)(perPage: 100)->data;
});
}
Checked, in order: (1) you hold entity.hierarchy.read; (2) the target is a
descendant of your entity at any depth — a sibling, a parent or a stranger
is refused as «not found», never «forbidden», so the error cannot be used to
probe which tenants exist; (3) the call is not made from inside a complex's
act — else ChildActRefusedException (already_acting); (4) the asking
context was not narrowed on purpose — a screen pinned to classrooms or grades,
a guardian's delegate, or an installation token with a signed-in person on the
same request — else ChildActRefusedException (narrowed); (5) there is a
grant to act under: the same app's active installation on the target, or —
for a person holding the permission — the read grant from your entity, else
MissingScopeException with the key entity.child_installation. The current
tenant and context are restored unconditionally after the closure, even
when it throws — your request must not go on running as the school on the
next line.
With no person, your entity's grant does not travel down. A scheduled task, a queue, a server: nobody is asking, so inside the school your app holds what the school granted, only, and a school that never installed it is refused as before. With a person holding the permission, your entity's reads travel down — never its writes.
Not on a request path. It asks nothing about the person where it matters: a
school that installed your app is entered under that school's own grant —
writes included — whoever is asking, even somebody the complex never let into
its schools. The person check above decides only whether your entity's reads
are added on top of that grant. Keep it for scheduled tasks, queues and
servers. On a request path a person reads the schools with ?entities= (or the
tree reads) and writes with ActInChildEntity — «Your app works inside a
complex's schools» below.
External apps: one token per entity
An external app's server already holds one installation token per tenant:
it reads the tree from the entity it is in, then reaches a child with that
child's token on the same API, and installed tells it whether such a token
can exist. ?entities= is not for it: a token has no person behind it, so it
is answered 403 entities_need_a_person. The surfaces of your app that a
user opens (a mini-app, a web page) use ?entities= like any other.
| Endpoint | Scope | Answers |
|---|---|---|
GET /entity |
(none) | where am I: type, is_container, parent_id |
GET /entity/children?descendants=1 |
entity.hierarchy.read |
who is beneath me, with installed and access per entity, and can_read_children |
GET /entity/children/{id} |
entity.hierarchy.read |
one child's profile: logo, contact, country, stage, sizes, access |
GET /entity/children/{id}/logo |
entity.hierarchy.read |
its logo — as a file, 404 when there is none |
GET /entity/ancestors |
entity.hierarchy.read |
whom I belong to, nearest first |
any data read with ?entities= / ?entity= |
the route's scope + entity.hierarchy.read |
the children's data — for a person holding tenants.children.view_members |
How an app declares «supports companies and complexes»
Working on a container needs no manifest flag. What makes an app work there is
three things in code: (1) check type/isContainer on open and draw the
matching screen; (2) request entity.hierarchy.read in your scopes whenever you
need the tree; (3) assume no depth. An app that does (1) alone keeps working on
a school exactly as before, and at head office shows a screen that says what it
is — instead of showing «no pupils» and looking broken.
The two declarations in this area answer different questions — not whether your app works on a container, but whether a container may put it in its schools so they cannot remove it (the next section), and whether a container installing it installs it in its schools too (the one after).
When the head-office screen is another app rather than a branch in the same
screen, do not ship it to every school: declare a row for the account type
narrowed by entity_types (chapter «Platform accounts», section «Entity type»),
and the platform picks the package by the entity's type — the complex's package
never reaches a school's phone.
A complex installs your app in its schools — hierarchy.managed_install
A complex (or an education company, or a university) can already install any store app in its schools from its «sub-entity apps» page — as each school's own copy, which the school removes whenever it likes. For some apps that is the wrong shape: the complex's attendance, its HR, its transport are decisions the complex takes for all its schools, and a school quietly removing one leaves a hole in the complex's picture that nobody notices until a report comes back short.
One flag in the manifest opts your app into the other shape:
{ "hierarchy": { "managed_install": true } }
With it on:
| Who | What happens |
|---|---|
| The complex | Installs the app for itself first — its own copy is what manages the schools' copies. Then chooses, on the «sub-entity apps» page, which entities beneath it get it, at any depth (a company reaches its complexes and their schools alike), and whether every entity that joins it later — created under it or moved under it — gets it automatically. |
| Each chosen school | Gets the app, and keeps everything about it: its settings, its notifications (a notification nobody at the school has configured follows the complex's setting), which of its staff may use it (the complex's operators also act inside it — «Your app works inside a complex's schools» below). It loses one thing — the uninstall button — and reads «installed by 403, error: managed_by_ancestor). |
| A school that already had your app | Keeps it as it is — taking it over widens nothing; the complex becomes the one that manages it. |
| Removal | The complex removes the app from one school on the same page, or from all of them at once by uninstalling its own copy. Either way it undoes what it did: a copy it installed is uninstalled; a copy the school had installed before is given back — still installed, unlocked. |
Three things to know before you turn it on:
- The complex approves every scope your app requests on each school's behalf — as the regular bulk install already does; taking over a copy the school installed itself widens nothing. And as on every install, each publish of yours sets every copy's scopes to what your manifest declares — the review is the platform's consent point. So request what the app needs and nothing more: on a managed install nobody at the school ticks the list.
- One container at a time. A school's copy is held by one container; a company that already manages it there is not overridden by a complex beneath it — the complex's page says who holds it.
- The flag governs new placements only. Turning it off in a later version stops complexes placing the app managed from then on, and stops the automatic placement in new schools; copies already placed stay managed until the complex removes them. Your publish never unlocks a school.
It is saved with the version: the «Integration» tab of the version editor,
set_managed_install over MCP, or sync_from_manifest from your repo — keep
the repo and the version in step, or the next sync writes the repo's value
back. validate_manifest refuses a "1" or a 1: okta-web honours a literal
true and nothing else.
Nothing in your code changes: a managed copy is an ordinary installation, with its own token, its own settings and the scopes approved for it.
A complex's install covers its schools — hierarchy.reach
The managed install above leaves the complex to choose the schools. For an app every school of the complex runs without exception — attendance, transport, dismissal — that choice is a step somebody forgets: the complex installs the app for itself, assumes it is now in its schools, and it is not. So you, as the developer, answer one question once: when a complex installs your app, what does the install cover?
reach |
What happens when a complex (an education company, a university) installs it |
|---|---|
"container" — the complex only |
It is installed in the complex alone and serves the complex. This is what happens for every app that does not name the key, so saying it and leaving it out read the same. |
"container_and_children" — the complex and its schools |
It is installed in the complex and, with it, in every entity beneath it at any depth as a managed copy (as in the previous section) the school cannot remove — with nobody choosing anything. Every entity that joins the complex later (created under it, or moved under it together with its schools) gets it automatically. The complex removing its own copy removes it from its schools. |
{ "hierarchy": { "reach": "container_and_children" } }
- It carries the managed install with it:
"container_and_children"places managed copies, so it meansmanaged_install: truewhether you write it or not — and the complex can still remove it from one particular school on its sub-entity apps page, as in the previous section."managed_install": falsebeside it is a contradictionvalidate_manifestand okta-web refuse by name. - Whoever presses «install» is asked about the schools too: the store
dialog tells the complex, before the button, that the app will be installed
in its entities and how many there are, and it refuses the install from a
person without the «install apps in sub-entities» permission (or on a plan
without it) — not a silent install on the complex alone. The store API refuses
it with
403,error: reach_not_allowed. - A school that installed it before the complex keeps it as it is, and the complex becomes the one who manages it; a copy a container higher up manages (a company above the complex) is left alone. A company installing it reaches its complexes and their schools together, and a complex it is placed in does not claim its schools first.
- The complex approves every scope your app requests on each school's behalf — and nobody at the school ticks the list. Request only what the app needs.
- The choice governs installs from now on. A complex that installed an
earlier version is not cascaded when you publish: your publish never installs
anything in a school its complex did not decide on. That complex can place it
in all its schools from its sub-entity apps page. The reverse too: going back
to
"container"stops new installs, and existing copies stay until the complex removes them. - Your app knows where it runs: the copy in a school is an ordinary install
with the school's token, and the complex's copy reads its schools with
?entities=as at the start of this chapter — and writes in them throughActInChildEntity(next section).
It is saved with the version: the «Integration» tab of the version editor (the
«When a complex installs your app, what does the install cover?» choice),
set_install_reach over MCP, or sync_from_manifest from your repo — keep the
repo and the version in step.
Your app works inside a complex's schools — ActInChildEntity
A complex that runs one gate for all its schools — arrivals at its door,
dismissal, the bus — writes records about pupils who are enrolled in its
schools, not in the complex. ?entities= reads those schools and never writes;
RunAsChildEntity enters a school without asking anything about the person.
ActInChildEntity is the path for a person operating the complex's copy of
your app: the platform asks for their authority at the complex, then runs
your code inside the pupil's school under that school's own copy of your
app — so the record, and the notice to the pupil's parents, land in the
school, with the school's settings.
In short: embedded apps only, in process — there is no HTTP route for it. The person holds two keys at the complex:
tenants.children.operate_appsand a permission of your app. Only schools whose copy of your app the complex's own installation manages (hierarchy.managed_install/hierarchy.reach) open. The school a record lands in is derived from the pupil on the server, never taken from the request.
Who it is for
- An embedded app, in process: services under
App\Services\PartnerApi\…. There is no partner HTTP route for acting inside a school — nothing under/api/apps/*, nothing in the API reference. - A mini-app writes through your own endpoints (behind
app.installation— chapter «Platform accounts», section «Which middleware builds the context»), and those endpoints call these services. It reads the schools with?entities=as before, or through your endpoints that call the tree reads below. - An external app's server stays on one token per entity: it writes into a school with that school's own token.
Two keys, both held at the complex
tenants.children.operate_apps— «Operate apps inside child entities». A platform permission: the globaltenant-adminrole holds it, and the complex grants it to any other group that runs your app across its schools.- A permission of your app for the act — say
my_app.arrivals.record— declared in yourrbac_permissionsor under your prefix. The operate permission is never accepted as "yours": the two keys stay two.
Either one alone is refused: your permission alone is usually what every
administrator of the complex holds, and the operate key alone names no act.
Both are read in the complex's own team, from the permission tables — not
through can() (a platform operator's blanket yes does not count), and not in
the school (a tenant-admin created inside the school by «Enter» holds nothing
here). And both are narrowed by the role the person came in by — the card
picked on the web, the X-Okta-Role-Id that app.installation verified on
mobile; a permission granted to the person directly, not through a role, is not
narrowed. If your mobile routes run a door of your own instead of
app.installation, pass that header's value to VerifyEnteredRole before the
first act (App\Services\PartnerApi\Identity\VerifyEnteredRole,
__invoke(int $tenantId, ?int $claimedRoleId): void): for a role the person
does not hold there it throws AccessDeniedHttpException (a 403, message
entered_role_not_assigned), and without the call nothing narrows — the person
acts with every role they hold at the complex.
Your app itself needs entity.hierarchy.read on the complex's copy.
Which schools open
Only a school whose active copy of your app is managed by the complex's own
installation — placed or adopted through hierarchy.managed_install or
hierarchy.reach (the two sections above). A copy the school installed itself
that nobody adopted, a copy a company above the complex manages, a school with
no copy, a suspended school: child_not_managed. It is asked again on every
call, so a complex that uninstalls its copy, or a school moved out from under
it, is closed from the next request.
Inside, your app holds the school's copy's grant and nothing else: no scope
of the complex's is added, and there is no fallback to the complex's grant.
Name the scopes your code needs in $requiredChildScopes; a school whose copy
lacks one is refused school_grant_missing:<scope> before your code runs.
One school — ActInChildEntity
__invoke(string $childEntityUlid, string $appPermission, Closure $callback, array $requiredChildScopes = []): mixed
— it returns what your closure returns.
Inside the closure Tenant::current() is the school; the module context is the
school's copy (its installation, and the scopes the school approved for it);
GetActiveEntityId() and GetCurrentEntity() answer the school;
DispatchNotification sends under the school's copy. All of it is restored in
finally, even when the closure throws.
The questions, in order — each word is the reason of
App\Exceptions\ChildActRefusedException unless the line says otherwise:
- Your app holds
entity.hierarchy.read— elseMissingScopeException. - The context is your app's own active installation at the entity it runs in
— else
not_the_app_root(a context carried down into a school is not a root). - Not already inside an act — else
already_acting: a school never acts on what is beneath it with the complex's authority. - The permission is your app's — else
InvalidArgumentException('app_permission_not_declared'); an empty or non-string entry in$requiredChildScopesisInvalidArgumentException('required_scope_invalid'). Bugs in your code, not answers about the person. - That entity is a container — else
not_container— and the ULID names a live entity beneath it — elsechild_entity_not_found, the same answer for a sibling, a parent, a stranger and a ULID that names nothing. - The actor, at the complex:
no_actor(nobody — a queued job, an installation token),narrowed(a door narrowed on purpose — a screen pinned to classrooms, a guardian's delegate),missing_permission(not a live member of the complex, or one of the two keys missing through the role they came in by),device_elsewhere(a panel paired to another entity). - The school's copy is managed by the complex's installation — else
child_not_managed. - The school's own grant holds every scope in
$requiredChildScopes— elseschool_grant_missing:<scope>.
Steps 1–4 are about your call, and are thrown before any school is looked at.
From step 5 on, the platform writes one audit line (app-children-act) per call
— per school, not per record — for every act and every refusal: who, through
which role, which permission, which school, the outcome. Nothing is remembered
between requests: revoking the operate permission or uninstalling the complex's
copy takes effect on the next one.
Let the exception through and the answer is one of two:
404 {"error": "child_entity_not_found"}, or
403 {"error": "child_act_refused", "reason": "…"}. To branch on it, catch
ChildActRefusedException (the policy scanner allows App\Exceptions\*) and
read reason, childEntityUlid and isNotFound().
Many pupils — ActForStudentsInTree
__invoke(array $studentUlids, string $appPermission, Closure $perSchool, array $requiredChildScopes = []): ChildActBatch
— your closure receives (string $entityUlid, array $studentUlids).
- It places each pupil in the entity they are enrolled in — on the server,
from their live enrolment — and runs your closure once per school through
ActInChildEntity: two hundred records across five schools are five acts. - Pupils enrolled at the complex itself run in place, under the complex's own
copy, with no switch — and with no two-key check and no audit line: they are
your app's own entity, guarded by whatever guards your route. At a school
(not a container) the call still works: that school's pupils run in place and
everybody else is
unplaced. - One school refusing never stops the others, and nothing for its pupils is written anywhere — a refused school never turns into a write under the complex.
ChildActBatch:results(entity ULID → what your closure returned),refused(entity ULID → the reason),unplaced(pupil ULIDs outside the complex, released, or naming nobody — one bucket on purpose),pupils(entity ULID → the pupils grouped there) andentityOf($studentUlid).- Thrown rather than recorded per school:
MissingScopeException,not_the_app_root,already_actingand theInvalidArgumentExceptions — they are about your call. An exception from your own closure is yours: it is not a refusal, and it propagates.
use App\Services\PartnerApi\Entity\Hierarchy\ActForStudentsInTree;
use App\Services\PartnerApi\Notifications\DispatchNotification;
use Modules\MyApp\Models\Arrival;
// The gate sends pupil ULIDs — and nothing about their schools.
$pupils = $request->input('students', []);
$batch = app(ActForStudentsInTree::class)(
$pupils,
'my_app.arrivals.record', // your permission, held at the complex beside operate_apps
function (string $schoolUlid, array $schoolPupils): int {
// Once per school, inside it: the tenant, your module context and the
// notification settings are that school's.
foreach ($schoolPupils as $pupilUlid) {
Arrival::create([
'school_ulid' => $schoolUlid, // where the pupil is enrolled — decided by the platform
'student_ulid' => $pupilUlid,
'arrived_at' => now(),
]);
app(DispatchNotification::class)('my-app.arrival.recorded', [
'recipient' => ['type' => 'parent_of_student', 'id' => $pupilUlid],
]);
}
return count($schoolPupils);
},
['education.students.read'], // each school's OWN copy must hold these
);
$batch->results; // ['01HN…A' => 12, '01HN…B' => 3]
$batch->refused; // ['01HN…C' => 'child_not_managed'] — nothing was written for those pupils
$batch->unplaced; // outside the complex, released, or unknown
Reading across the tree — without entering a school
The tree reads run at the complex and never switch. Each one asks the same two
keys (your permission here is the read's — say my_app.arrivals.view), and
reads each school under that school's copy's grant. That is what separates
them from ?entities=, which asks tenants.children.view_members and reads any
school beneath with either grant: the tree is the complex and the schools it
manages, nothing else.
| Call | Answers | Scope besides entity.hierarchy.read |
|---|---|---|
ListOperableChildEntities(string $appPermission): OperableEntitiesDto |
The probe: refusal (null, or the word) and entities — the complex first, then its managed schools by name. Each OperableEntityDto has id (ULID), key, name, type, isRoot, scopes (that copy's own grant) |
— |
ListOperableStudents(string $appPermission, int $page = 1, int $perPage = 500, ?string $entityUlid = null, ?string $gradeUlid = null, ?string $sectionUlid = null, ?string $search = null): PaginatedResult |
The complex's own pupils and every operable school's, in one list, ordered by school, then pupil; each StudentDto carries entityId and entityName; skipped_entities sits in extra |
education.students.read |
GetOperableStudent(string $appPermission, string $studentUlid): StudentDto |
One pupil, with their school in entityId — the server's answer |
education.students.read |
GetOperableStudentPhoto(string $appPermission, string $studentUlid): ?string |
The photograph's path on the public disk, or null — serve it through a route of your own |
education.student_photos.read |
ListOperableStudentFingerprints(string $appPermission, int $page, int $perPage = 1000): array |
The card roster: students rows of {student_id, full_name, grade_id, section_id, entity_id, entity_name, fingerprints}, and meta with skipped_entities |
tool_keys.links.read |
The scope in the last column is asked twice: of the complex's copy (else
MissingScopeException) and of each school's copy. A managed school whose copy
lacks it is named in skipped_entities ({id, name, missing_scope}) — never
listed as a school with nobody in it — and, for a single pupil, answered as not
found.
- The probe answers with a value.
ListOperableChildEntitiesnever throws a refusal:refusalismissing_scope,not_the_app_rootor one of the act words, with an empty list — ask it before you draw the complex's screen. A complex whose copy manages no school answers[the complex]and no refusal. Only a permission that is not yours is thrown.ActInChildEntity::refusal($appPermission)answers the same question about the person without listing anything. - The other four throw. A scope missing from the complex's copy —
entity.hierarchy.reador the read's own — isMissingScopeException. Every other refusal isApp\Exceptions\ChildEntitiesNotOperableExceptionwith areason—not_the_app_root,already_acting,not_container, the actor words (no_actor,narrowed,missing_permission,device_elsewhere), andtree_too_largeon the two rosters — rendered to a JSON or API caller as403 {"error": "children_not_operable", "reason": "…"}. - The ceiling refuses; it never trims.
ListOperableStudentsand the card roster refuse a tree of more than 10,000 live pupils or 50 schools astree_too_large, on every call, whatever it filters: a list cut short turns the pupils past the cut into strangers at the door. - One 404 for everything outside.
GetOperableStudentandGetOperableStudentPhotothrow the sameModelNotFoundExceptionfor a pupil of another customer, a released pupil, a pupil of a school the complex does not operate, and a ULID that names nobody. - Fingerprints, never card codes. The roster has no
codeskey, and every fingerprint is under the complex's secret — the one the Okta app uses on a scan when it is open at the complex. So the gate's scans match these rows, and not a school's own roster. - The identity number and the photograph appear on a row only when the complex's copy and that pupil's school's copy both hold the scope; the identity part of a search runs only when every school searched granted it.
ListOperableStudentslists released pupils, as a school's own listing does; the card roster leaves them out.key(onOperableEntityDto, and onManagingEntityDtobelow) is the platform's number for the entity — the valueGetActiveEntityId()answers inside that school — handed to your code for your own tables.toArray()leaves it out; never send it anywhere.
Who manages this copy — GetManagingEntity
__invoke(): ?ManagingEntityDto (scope entity.hierarchy.read): the container
whose copy manages the copy you are standing in — id (ULID), key, name,
and clock, a TenantClockDto whose source says which link of the chain
answered (platform, tenant, country or default). Null for a copy nobody
manages, which is the everyday answer. It is read from the live lock, so a
complex that uninstalled its copy, or a school moved away, answers null at once.
Inside an act the copy standing there is the school's, so it answers with the
complex doing the writing — the place a school's settings fall back to.
The viewer inside the act
GetCurrentViewer inside your closure returns the complex's viewer — the
complex's entityId, its roles and groups, and your permissions held there,
because that is where the authority was asked — with actingInEntityId set to
the school's ULID and isActingBeneath() true. Stamp the record with the
school, never with entityId: inside the act, entityId is the complex.
Outside an act nothing changes (actingInEntityId is null), and a device is
never a viewer (null).
The school comes from the pupil, on the server
No header, no body field and no query parameter says which school a record
lands in. A school_id sent by the client is a claim, even when it looks right.
For a batch, ActForStudentsInTree places the pupils; for one pupil,
GetOperableStudent($appPermission, $studentUlid)->entityId answers. A filter
such as entityUlid on ListOperableStudents narrows inside the tree the
authority already decided — a ULID outside it is an empty page, never a school
opened.
Notifications land in the school
- Inside an act,
DispatchNotificationsends under the school's copy: the guardians are the pupil's guardians in that school, and the delivery row is the school's. - A school's managed copy follows the complex's setting for a notification
until somebody at the school configures that row: the complex's switch,
channels, quiet hours and wording. The recipients always stay the school's
own, and a provider template id carries over only when both entities send that
channel through the same provider app. Once somebody at the school edits the
row — and a row that was already switched on, reworded or changed before
counts as edited — the school's own row governs, a deliberate off included,
until they press «Follow
». Nothing in your code changes. - Dispatched from the complex's own context to the parents of a pupil of a
school beneath it (
parent_of_student), a notification reaches nobody: one delivery row, channelall, statusfailed, errorrecipient_in_child_entity:<school ULID> — …, and nothing is sent. It cannot be resent — a resend would run in the same place. Send it from inside the school, throughActInChildEntityorActForStudentsInTree.
What is not here
- Paired panels. The rule for a device is already in the platform — a panel
paired to the complex acts through its role's grants, the same two keys;
paired elsewhere it is
device_elsewhere; a screen pinned to places isnarrowed— but no platform surface opens your app for an operator panel yet. Today the one acting is a person. RunAsChildEntityis not this. It asks nothing about the person: a school that installed your app is entered under its own grant — writes included — whoever is asking. Beyond the tree and grant checks it refuses only a narrowed door (narrowed) and a call from inside an act (already_acting) — «Acting inside a child» above. It is for scheduled tasks, queues and servers with nobody asking. On a request path, read with?entities=or the tree reads above, and write withActInChildEntity.- The launch marker is not yours to read. A native launch at a complex
carries
"operates": {"scope": "tree"}when the person, through the role they opened with, may operate the complex's schools for your app and its copy manages at least one; a school's launch — and any launch that does not qualify — has nooperateskey at all. Only the Okta app reads it: it is not inOkta.context()and never reaches your server. What you see of it ispackage:okta_faceloading the complex's operated tree instead of one school (chapter «nativemode», section «Who the recognition camera saw»). - Nothing to declare. No new manifest key, no new scope, no
minContract. What makes it work is what this chapter already has:hierarchy.managed_installorhierarchy.reach(so there are managed copies),entity.hierarchy.read, and your permission inrbac_permissions.
A complex's departments — GET /entity/departments
A complex may split its schools into departments (optional): each has a manager, staff and its own schools, and a school sits in one department at most. A department's staff see their department's schools alone, on the platform and in your app alike.
In short: nothing to declare and no new scope. The platform fences the tree reads and acts to the person's department by itself, and
GET /api/apps/entity/departments(behindentity.hierarchy.read) tells you the departments and where the person stands, so your app can draw schools under their department and explain a missing school instead of looking broken.
What the platform fences for you
For a person in a department of the entity your app is installed on, the schools outside it do not exist on every tree path:
| Path | Outside the department |
|---|---|
/entity/children (and ?descendants=1) |
not listed |
/entity/children/{id} and its logo |
404 entity_not_found |
?entity=<ulid> / ?entities=… |
entity_not_found for that school |
ActInChildEntity |
child_entity_not_found |
The operated tree reads (ListOperableChildEntities and its siblings) |
not listed |
The whole tree is seen by anyone in no department, and by whoever holds
tenants.children.manage_departments (the one who assigns schools to
departments). A complex administrator who also manages a department chooses at
sign-in between the «complex» card and the «manager of department X» card; the
second fences them to that department. Your server on an installation token
is never fenced: there is no person to ask about.
So never cache the school list from one person to show another: two staff of the same entity can get different lists.
The answer
{
"data": [
{
"id": "01JB…",
"name": "Boys' department",
"description": null,
"manager": { "name": "Khalid" },
"staff": 4,
"schools": ["01JA…", "01JC…"]
}
],
"viewer": {
"manages": ["01JB…"],
"member_of": [],
"acting_department": "01JB…",
"reach": ["01JA…", "01JC…"]
}
}
schoolsare the schools' ULIDs as/entity/childrengives them, so join on the id.viewerisnullwhen no person is behind the request (a server).viewer.reachisnullwhen the person sees the whole tree, else what they reach — and it can be empty: a department with no schools leaves its staff on nothing, not on everything.viewer.acting_departmentis the department a complex administrator entered as, ornull.- An entity with no departments answers
data: [], and everything works as before.
use App\Services\PartnerApi\Entity\Hierarchy\ListDepartments;
$answer = app(ListDepartments::class)(); // embedded apps: the same shape
A mini app reads it with Okta.get('/api/apps/entity/departments').
Supporting departments in your app
- Read the schools from the platform every session, not from a stored
copy:
/entity/childrengives exactly what this person reaches. - Group the schools under their departments on the head-office screen:
read
/entity/departments, and show «no department» for schools none holds. - Say why the list is short:
viewer.reachnotnull⇒ «showing your department's schools»; empty ⇒ «no schools in your department yet, ask the complex administrator» — never a silent empty screen. - Do not build your own fence: do not read the departments table or compare schools yourself before reading or writing. The platform refuses what is outside the department, and an app that fences on its own drifts from it the day a department changes.
- Reports and totals across the tree take their schools from the same
/entity/children, so a department's staff never sum schools they cannot see.