An employer is a tree, and an application attaches to any part of it #138

Closed
opened 2026-09-09 10:36:56 +00:00 by tiagoagueda · 1 comment
Owner

Observation

one internal plugin that answer a open issue (company hierarquy)
parent company > child company > departement > person of contact
any job application be be attached to any of the four

Extends #55, which specifies the parent-and-child half and is not superseded by this.

What exists

The chain from employer to attempt is fixed and one level deep:

Company ──< JobPosting ──< Application
                              └── contact → Contact (optional, SET_NULL)
Company ──< Contact

JobPosting.company is not nullable, so every application already has exactly one
company, reached through its posting. Application.contact is already an optional direct
link to a person. Two of the four attachment points therefore exist in some form; the
company one is mandatory and indirect, the contact one is optional and direct.

Missing: a parent on Company (#55), a Department anywhere, and any way for an
application to say it was for a part of an employer rather than the whole of it.

What this asks for

The employer as a structure a person can record, offered as an internal plugin, with an
application able to name whichever part of it the attempt was actually aimed at.

Worth being careful about

It is not one four-level tree. It is three different edges, and calling them one thing
would produce a model that cannot answer either question:

Edge Kind of relation Depth
company → company ownership arbitrary — Alphabet > Google > Google Ireland
company → department internal structure usually one, sometimes nested
department → contact membership one

#55 already proposes the first as a self-referential nullable parent, a tree rather than a
graph, with cycles refused and depth capped. A department hangs off any company in that
chain, not only off a leaf. Modelling all three as one recursive parent would let somebody
make a company the child of a person.

"Attached to any of the four" risks two answers to one question. An application's
employer is application.posting.company today, and that column is not nullable. Add a
direct attachment on the application and there are two places that say who this was for,
which will disagree the first time a posting is edited. Three shapes, and they are not
equally safe:

  1. The finer attachment enriches the existing one. The posting keeps its company; the
    application optionally names a department or a contact within it, and anything outside
    that company's subtree is refused. One source of truth, and the fine detail is additive.
  2. The employer link itself becomes polymorphic — the posting points at a company, a
    department, or a contact — and "which company" becomes a walk up the tree. One source of
    truth, a larger migration, and every count in the application learns to walk.
  3. Both, independent. Fastest to write and the one that goes wrong.

This is the decision the issue turns on, and it should be made before anything is built.

Every count in Postulo means "at this company" today. Insights, the companies table,
the industries widget, applications at this company on the company page. With a tree, each
has to answer whether it counts the group or the node — #55 already says Insights gains that
choice, and the same question arrives everywhere else. A hierarchy that silently keeps
counting leaves is a hierarchy that changes nothing.

A plugin that can be switched off must leave every application with an employer. #90
settled that switching a plugin off deletes nothing and shows the primary; the same shape
works here — off means the department and the parent stop being shown and the company
remains, which is exactly today's behaviour. That is only true under shape 1 above. Under
shape 2, switching the plugin off would leave applications attached to departments with no
company to fall back to, which is a plugin toggle that breaks a page. Worth deciding with
that in view.

Merging companies exists and gets harder. Merge with moves one company's records into
another. With a tree it also has to decide what happens to children, to departments, and to
a merge that would make a company its own ancestor.

Deleting a parent must not delete a group. JobPosting.company and Contact.company are
CASCADE; a parent link must not be, or removing a holding company removes every subsidiary
and every posting under it. SET_NULL, and the children become roots.

The export and the import carry the shape. #55 proposes resolving the tree by name within
the archive; departments and the application's attachment travel the same way, and the
importer still reads an archive written before any of this.

Wikidata knows most of the ownership edges (P749, P355) and #42 means many companies
already carry an identifier — but #55 deliberately leaves suggesting a parent out, and this
should too. The relation gets recorded by hand first.

As a plugin, this owns three things: a column on someone else's model (Company.parent),
a new model (Department), and possibly a column on Application. A plugin adding a field
to a core model is a shape no plugin here has yet — the phone numbers feature added a table
and a GenericRelation, which is not the same thing — and #126 and #128 are where that gets
settled.

## Observation > one internal plugin that answer a open issue (company hierarquy) > parent company > child company > departement > person of contact > any job application be be attached to any of the four Extends #55, which specifies the parent-and-child half and is not superseded by this. ## What exists The chain from employer to attempt is fixed and one level deep: ``` Company ──< JobPosting ──< Application └── contact → Contact (optional, SET_NULL) Company ──< Contact ``` `JobPosting.company` is **not nullable**, so every application already has exactly one company, reached through its posting. `Application.contact` is already an optional direct link to a person. Two of the four attachment points therefore exist in some form; the company one is mandatory and indirect, the contact one is optional and direct. Missing: a parent on `Company` (#55), a `Department` anywhere, and any way for an application to say it was for a part of an employer rather than the whole of it. ## What this asks for The employer as a structure a person can record, offered as an internal plugin, with an application able to name whichever part of it the attempt was actually aimed at. ## Worth being careful about **It is not one four-level tree. It is three different edges**, and calling them one thing would produce a model that cannot answer either question: | Edge | Kind of relation | Depth | | --- | --- | --- | | company → company | ownership | arbitrary — *Alphabet > Google > Google Ireland* | | company → department | internal structure | usually one, sometimes nested | | department → contact | membership | one | #55 already proposes the first as a self-referential nullable `parent`, a tree rather than a graph, with cycles refused and depth capped. A department hangs off *any* company in that chain, not only off a leaf. Modelling all three as one recursive parent would let somebody make a company the child of a person. **"Attached to any of the four" risks two answers to one question.** An application's employer is `application.posting.company` today, and that column is not nullable. Add a direct attachment on the application and there are two places that say who this was for, which will disagree the first time a posting is edited. Three shapes, and they are not equally safe: 1. *The finer attachment enriches the existing one.* The posting keeps its company; the application optionally names a department or a contact within it, and anything outside that company's subtree is refused. One source of truth, and the fine detail is additive. 2. *The employer link itself becomes polymorphic* — the posting points at a company, a department, or a contact — and "which company" becomes a walk up the tree. One source of truth, a larger migration, and every count in the application learns to walk. 3. *Both, independent.* Fastest to write and the one that goes wrong. This is the decision the issue turns on, and it should be made before anything is built. **Every count in Postulo means "at this company" today.** Insights, the companies table, the industries widget, *applications at this company* on the company page. With a tree, each has to answer whether it counts the group or the node — #55 already says Insights gains that choice, and the same question arrives everywhere else. A hierarchy that silently keeps counting leaves is a hierarchy that changes nothing. **A plugin that can be switched off must leave every application with an employer.** #90 settled that switching a plugin off deletes nothing and shows the primary; the same shape works here — off means the department and the parent stop being shown and the company remains, which is exactly today's behaviour. That is only true under shape 1 above. Under shape 2, switching the plugin off would leave applications attached to departments with no company to fall back to, which is a plugin toggle that breaks a page. Worth deciding with that in view. **Merging companies exists and gets harder.** *Merge with* moves one company's records into another. With a tree it also has to decide what happens to children, to departments, and to a merge that would make a company its own ancestor. **Deleting a parent must not delete a group.** `JobPosting.company` and `Contact.company` are `CASCADE`; a parent link must not be, or removing a holding company removes every subsidiary and every posting under it. `SET_NULL`, and the children become roots. **The export and the import carry the shape.** #55 proposes resolving the tree by name within the archive; departments and the application's attachment travel the same way, and the importer still reads an archive written before any of this. **Wikidata knows most of the ownership edges** (`P749`, `P355`) and #42 means many companies already carry an identifier — but #55 deliberately leaves *suggesting* a parent out, and this should too. The relation gets recorded by hand first. **As a plugin, this owns three things**: a column on someone else's model (`Company.parent`), a new model (`Department`), and possibly a column on `Application`. A plugin adding a field to a core model is a shape no plugin here has yet — the phone numbers feature added a table and a `GenericRelation`, which is not the same thing — and #126 and #128 are where that gets settled.
tiagoagueda added this to the 0.3.0 milestone 2026-09-09 10:36:56 +00:00
Author
Owner

Done in e2c74cc5, the last of three: #55 gave a company a parent, #137 gave a
company departments, and this is the attachment, the switch, and the counting.

The decision the issue turns on

Shape 1 — the finer attachment enriches the existing one. Your own plugin-toggle
analysis settles it, and I have made that argument a test rather than a paragraph:

A plugin that can be switched off must leave every application with an employer. […] That
is only true under shape 1. Under shape 2, switching the plugin off would leave
applications attached to departments with no company to fall back to, which is a plugin
toggle that breaks a page.

So JobPosting.company stays not nullable and Application.department is an optional
addition beside it. test_off_leaves_every_application_with_an_employer asserts exactly
that consequence.

"Attached to any of the four" is three fields, not four

Worth saying plainly, because it changes what needed building:

Attachment Where it lives
parent company posting.company — a company at any height of the tree
child company posting.company — the same column, a different row
department new: Application.department
person of contact Application.contact, already there

A posting's company is the company applied to, whatever height it sits at. The tree turns
"which group" into a question you can ask, rather than into a fourth place to attach. Only
the department was actually missing.

Three edges, kept apart

As you set out: ownership nests arbitrarily, internal structure does not, membership is one
link. Three fields, each refusing what does not belong on it —
test_the_three_edges_are_three_fields and test_ownership_nests_and_the_other_two_do_not
hold that shape.

A department anywhere in the employer's group may be named, not only at the company on
the posting: an application through the Irish arm can be for the group's engineering team,
and refusing that would make the tree decorative. Anywhere outside it is refused in a
sentence naming the employer it is actually at
, rather than silently cleared.

Every count answers group-or-node

A hierarchy that silently keeps counting leaves is a hierarchy that changes nothing.

  • The company page counts that company, exactly as it always did, and offers Across the
    {group} group
    beside the heading where there is a group — naming the company each posting
    is at when it is showing more than one. In the address, so a page counting a whole group is
    a thing to bookmark and send.
  • The default did not move. Changing what a number means without being asked would be
    the other half of the same mistake, so this company stays what it has always been.
  • The companies table gains a Part of column whose name links to that whole ownership
    tree, grandchildren included. Each row still counts itself, which is true and visible.

That last is a walk rather than a join: a chain is arbitrarily deep and the cap is ten,
so expressing "anywhere in this group" as a lookup would be ten outer joins on every row of
every page. Two small queries instead, and an unknown name narrows to nothing rather than to
everything.

As a plugin

employer-structure, a feature plugin, on by default so no upgrade takes anything
away. Off is exactly what Postulo did before any of this existed — one company per posting,
contacts hanging directly off it — and deletes nothing: the parent links, the departments
and the attachments stay where they are and come back untouched. The gate lives in
jobs/structure.py so "off shows the company alone" is one sentence in the code as well as
in the interface.

It adds no column to a core model on its own account — the models are Postulo's, because
they are a person's data and Postulo does the ownership scoping. What the plugin owns is the
answer to is this offered.

Two of your concerns, resolved differently than expected

"Merging companies exists and gets harder." It does not, because it does not exist: only
Industry has a merge with, and companies have no merge at all. Nothing to make harder.
If a company merge is wanted, it is its own issue and the tree is one more thing it will
have to answer for.

The export surfaced a bug older than this change. A department travelled only as a name
beside a contact — so a team with nobody in it vanished from the archive entirely, and a
team you applied to before you knew anybody there is exactly the ordinary case #137 was
written for. Departments are records of their own in the file now.
test_a_team_nobody_is_recorded_at_survives_the_archive was written after the new attachment
failed to restore and the reason turned out to be that, rather than anything in this issue.

FORMAT_VERSION is 13; the attachment travels by department name and by the company
holding it, resolved in a second pass after the ownership tree for the same reason that one
needs a second pass. Every earlier format still imports.

Also

tests/test_employer_structure.py (34). Suite 4739 passed, 29 skipped; browser suite 86
passed. Twelve strings — ten core and the plugin's own two — filled in all 39 European
catalogues, in the plugin's own locale/ for its half. Wiki: three new sections under
Tracking applications.

Shipped on 0.3.0, with main kept level.

Done in `e2c74cc5`, the last of three: #55 gave a company a parent, #137 gave a company departments, and this is the attachment, the switch, and the counting. ## The decision the issue turns on **Shape 1 — the finer attachment enriches the existing one.** Your own plugin-toggle analysis settles it, and I have made that argument a test rather than a paragraph: > A plugin that can be switched off must leave every application with an employer. […] That > is only true under shape 1. Under shape 2, switching the plugin off would leave > applications attached to departments with no company to fall back to, which is a plugin > toggle that breaks a page. So `JobPosting.company` stays **not nullable** and `Application.department` is an optional addition beside it. `test_off_leaves_every_application_with_an_employer` asserts exactly that consequence. ## "Attached to any of the four" is three fields, not four Worth saying plainly, because it changes what needed building: | Attachment | Where it lives | | --- | --- | | parent company | `posting.company` — a company at any height of the tree | | child company | `posting.company` — the same column, a different row | | department | **new**: `Application.department` | | person of contact | `Application.contact`, already there | A posting's company *is* the company applied to, whatever height it sits at. The tree turns "which group" into a question you can ask, rather than into a fourth place to attach. Only the department was actually missing. ## Three edges, kept apart As you set out: ownership nests arbitrarily, internal structure does not, membership is one link. Three fields, each refusing what does not belong on it — `test_the_three_edges_are_three_fields` and `test_ownership_nests_and_the_other_two_do_not` hold that shape. A department **anywhere in the employer's group** may be named, not only at the company on the posting: an application through the Irish arm can be for the group's engineering team, and refusing that would make the tree decorative. Anywhere outside it is refused **in a sentence naming the employer it is actually at**, rather than silently cleared. ## Every count answers group-or-node > A hierarchy that silently keeps counting leaves is a hierarchy that changes nothing. - **The company page** counts that company, exactly as it always did, and offers *Across the {group} group* beside the heading where there is a group — naming the company each posting is at when it is showing more than one. In the address, so a page counting a whole group is a thing to bookmark and send. - **The default did not move.** Changing what a number means without being asked would be the other half of the same mistake, so *this company* stays what it has always been. - **The companies table** gains a **Part of** column whose name links to that whole ownership tree, grandchildren included. Each row still counts itself, which is true and visible. That last is a **walk rather than a join**: a chain is arbitrarily deep and the cap is ten, so expressing "anywhere in this group" as a lookup would be ten outer joins on every row of every page. Two small queries instead, and an unknown name narrows to nothing rather than to everything. ## As a plugin `employer-structure`, a `feature` plugin, **on by default** so no upgrade takes anything away. Off is exactly what Postulo did before any of this existed — one company per posting, contacts hanging directly off it — and **deletes nothing**: the parent links, the departments and the attachments stay where they are and come back untouched. The gate lives in `jobs/structure.py` so "off shows the company alone" is one sentence in the code as well as in the interface. It adds no column to a core model on its own account — the models are Postulo's, because they are a person's data and Postulo does the ownership scoping. What the plugin owns is the answer to *is this offered*. ## Two of your concerns, resolved differently than expected **"Merging companies exists and gets harder."** It does not, because it does not exist: only `Industry` has a *merge with*, and companies have no merge at all. Nothing to make harder. If a company merge is wanted, it is its own issue and the tree is one more thing it will have to answer for. **The export surfaced a bug older than this change.** A department travelled only as a name beside a contact — so **a team with nobody in it vanished from the archive entirely**, and a team you applied to before you knew anybody there is exactly the ordinary case #137 was written for. Departments are records of their own in the file now. `test_a_team_nobody_is_recorded_at_survives_the_archive` was written after the new attachment failed to restore and the reason turned out to be that, rather than anything in this issue. `FORMAT_VERSION` is **13**; the attachment travels by department name *and* by the company holding it, resolved in a second pass after the ownership tree for the same reason that one needs a second pass. Every earlier format still imports. ## Also `tests/test_employer_structure.py` (34). Suite 4739 passed, 29 skipped; browser suite 86 passed. Twelve strings — ten core and the plugin's own two — filled in all 39 European catalogues, in the plugin's own `locale/` for its half. Wiki: three new sections under *Tracking applications*. Shipped on `0.3.0`, with `main` kept level.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
Postulo/postulo#138
No description provided.