Bind a listing to what it came from and what arrives about it: a capture, an email, a document, a message #270

Open
opened 2026-09-17 20:01:12 +00:00 by tiagoagueda · 1 comment
Owner

A listing can be bound to exactly one other thing today: the capture it came from, through
Capture.posting. Beyond that it carries a url, a source string ("Found via") and
nothing else. Everything that arrives about a job before you apply for it has nowhere to
go.

  • The counsellor at the employment service sends the advert as a message.
  • A contact forwards the job description as a PDF.
  • The board emails a "this closes on Friday" reminder, which the IMAP plugin sees.
  • The same advert is captured a second time from a different board.
  • Somebody writes down what they were told on the phone about the role.

None of those can be attached to the listing. They are kept somewhere else or lost, and the
listing — the thing the person is actually deciding about — stays a title, a company and a
link.

The asymmetry this is really about

An application has a timeline. A listing has nothing.

ApplicationEvent is append-only, scoped through its parent, and already has the right
vocabulary: EMAIL_SENT, EMAIL_RECEIVED, CALL, NOTE, INTERVIEW, OTHER. Everything
after you apply is recorded, and record_event is the one way in.

Everything before you apply is not recorded at all. That is the gap, and it is why this is
not a request for an attachments box: the listing stage has no history, and half the
decisions a job seeker makes happen there.

The question that has to be answered before any code

Is this an event, or an attachment?

CLAUDE.md states the principle: the event log is the truth. That argues for extending the
log downward rather than adding a parallel table — two histories for one record drift, and
#217 is what happens when a record's past has more than one home.

But ApplicationEvent was deliberately built the way it is:

Events carry no owner of their own: duplicating it would create a second source of truth
that could drift out of step with the application it belongs to.

It scopes through application__owner. Giving an event two possible parents means scoping
through either, which is exactly the kind of thing that is cheap to write and expensive to
get wrong — and tests/security/ would need to prove it for both paths.

Three shapes, and one of them should be chosen here rather than discovered:

  1. One event model, generically parented. A single timeline; scoping goes through
    whichever parent it has. Fewest concepts, most careful scoping.
  2. A separate ListingEvent. Simpler scoping, simpler cascade, two models that will be
    asked to converge later — particularly when a listing becomes an application.
  3. An attachment model beside the event log. Honest that a forwarded PDF is not the same
    kind of thing as "status changed", but reintroduces the second history the principle warns
    against.

Whichever wins has to answer the same question: when a listing becomes an application, what
happens to what was bound to it?
Carried over, referenced, or left behind — that decision
determines the model, not the other way round.

Precedents in the tree, so a fourth shape is not invented

  • core.PhoneNumber, core.PostalAddress and core.WebLink attach to holders through
    GenericRelation, with the cascade that implies.
  • RenderedDocument.source is a GenericForeignKey with no reverse GenericRelation,
    and the docstring says why: a reverse relation would give a cascade, and "the whole point
    of this model is that a PDF an employer received survives somebody tidying up their
    drafts"
    .

That second one is the more interesting precedent here. A forwarded job description should
probably survive the listing being discarded, and a note about it probably should not.

What else it touches

  • Plugins have to be able to bind. The IMAP plugin seeing a mail about a listing, an MCP
    client, a future messaging source — all of them need a way to say "this belongs to that".
    The plugin is handed a record it may already see and never chooses the owner. This is
    squarely #229's surface, and should be designed with it rather than after it.
  • Files go through the existing machinery. A bound PDF lives under the owner's path, as
    logos/{owner_id}/ does, is served by serve_private_file, and is removed from disk when
    the record goes — #217 is the issue that establishes this is not automatic.
  • Bound text is foreign text. An email body or a message from somebody else is exactly
    what #218 was about: text a stranger wrote reaching CSV, ICS, the MCP plugin and the page.
    Anything bound here inherits that whole problem, and #218's fixes have to cover it.
  • Duplicates become a feature. jobs/known.py already asks whether an advert has been
    seen before, and deliberately tells rather than refuses (#178). A second capture of the
    same job is a natural thing to bind rather than discard — the same advert on two boards
    is evidence about the job, not noise.
  • #239 (why it ended, who referred you, which agency) and #240 (more events than
    three) are the two neighbours. #239 in particular is asking for structure on the same
    record; these should be read together before either is built.

Scope

What this is not: a file manager, a mailbox, or a place to keep documents that belong in
documents/. A binding says this artefact relates to that listing and points at something
already stored where it belongs.

A listing can be bound to exactly one other thing today: the capture it came from, through `Capture.posting`. Beyond that it carries a `url`, a `source` string ("Found via") and nothing else. Everything that arrives *about* a job before you apply for it has nowhere to go. - The counsellor at the employment service sends the advert as a message. - A contact forwards the job description as a PDF. - The board emails a "this closes on Friday" reminder, which the IMAP plugin sees. - The same advert is captured a second time from a different board. - Somebody writes down what they were told on the phone about the role. None of those can be attached to the listing. They are kept somewhere else or lost, and the listing — the thing the person is actually deciding about — stays a title, a company and a link. ## The asymmetry this is really about **An application has a timeline. A listing has nothing.** `ApplicationEvent` is append-only, scoped through its parent, and already has the right vocabulary: `EMAIL_SENT`, `EMAIL_RECEIVED`, `CALL`, `NOTE`, `INTERVIEW`, `OTHER`. Everything after you apply is recorded, and `record_event` is the one way in. Everything *before* you apply is not recorded at all. That is the gap, and it is why this is not a request for an attachments box: the listing stage has no history, and half the decisions a job seeker makes happen there. ## The question that has to be answered before any code **Is this an event, or an attachment?** `CLAUDE.md` states the principle: *the event log is the truth*. That argues for extending the log downward rather than adding a parallel table — two histories for one record drift, and #217 is what happens when a record's past has more than one home. But `ApplicationEvent` was deliberately built the way it is: > Events carry no owner of their own: duplicating it would create a second source of truth > that could drift out of step with the application it belongs to. It scopes through `application__owner`. Giving an event two possible parents means scoping through either, which is exactly the kind of thing that is cheap to write and expensive to get wrong — and `tests/security/` would need to prove it for both paths. Three shapes, and one of them should be chosen here rather than discovered: 1. **One event model, generically parented.** A single timeline; scoping goes through whichever parent it has. Fewest concepts, most careful scoping. 2. **A separate `ListingEvent`.** Simpler scoping, simpler cascade, two models that will be asked to converge later — particularly when a listing becomes an application. 3. **An attachment model beside the event log.** Honest that a forwarded PDF is not the same kind of thing as "status changed", but reintroduces the second history the principle warns against. **Whichever wins has to answer the same question: when a listing becomes an application, what happens to what was bound to it?** Carried over, referenced, or left behind — that decision determines the model, not the other way round. ## Precedents in the tree, so a fourth shape is not invented - `core.PhoneNumber`, `core.PostalAddress` and `core.WebLink` attach to holders through `GenericRelation`, with the cascade that implies. - `RenderedDocument.source` is a `GenericForeignKey` with **no** reverse `GenericRelation`, and the docstring says why: a reverse relation would give a cascade, and *"the whole point of this model is that a PDF an employer received survives somebody tidying up their drafts"*. That second one is the more interesting precedent here. A forwarded job description should probably survive the listing being discarded, and a note about it probably should not. ## What else it touches - **Plugins have to be able to bind.** The IMAP plugin seeing a mail about a listing, an MCP client, a future messaging source — all of them need a way to say "this belongs to that". The plugin is handed a record it may already see and never chooses the owner. This is squarely #229's surface, and should be designed with it rather than after it. - **Files go through the existing machinery.** A bound PDF lives under the owner's path, as `logos/{owner_id}/` does, is served by `serve_private_file`, and is removed from disk when the record goes — #217 is the issue that establishes this is not automatic. - **Bound text is foreign text.** An email body or a message from somebody else is exactly what #218 was about: text a stranger wrote reaching CSV, ICS, the MCP plugin and the page. Anything bound here inherits that whole problem, and #218's fixes have to cover it. - **Duplicates become a feature.** `jobs/known.py` already asks whether an advert has been seen before, and deliberately tells rather than refuses (#178). A second capture of the same job is a natural thing to *bind* rather than discard — the same advert on two boards is evidence about the job, not noise. - **#239** (why it ended, who referred you, which agency) and **#240** (more events than three) are the two neighbours. #239 in particular is asking for structure on the same record; these should be read together before either is built. ## Scope What this is not: a file manager, a mailbox, or a place to keep documents that belong in `documents/`. A binding says *this artefact relates to that listing* and points at something already stored where it belongs.
tiagoagueda added this to the 0.5.0 milestone 2026-09-17 20:01:12 +00:00
Author
Owner

A first consumer for whatever this issue settles: #271 proposes a Thunderbird MailExtension that binds the message you are reading to a listing, with no mailbox credentials on the server.

It raises one thing that belongs here rather than there: none of the existing API scopes fits. Binding a mail is not capturing, and the write scope covers applications, listings, notes, reminders and letters — far more than a mail client should hold. Whatever binding shape this issue chooses should come with a scope narrow enough to hand to a client that only attaches things.

A first consumer for whatever this issue settles: #271 proposes a Thunderbird MailExtension that binds the message you are reading to a listing, with no mailbox credentials on the server. It raises one thing that belongs here rather than there: **none of the existing API scopes fits.** Binding a mail is not capturing, and the write scope covers applications, listings, notes, reminders and letters — far more than a mail client should hold. Whatever binding shape this issue chooses should come with a scope narrow enough to hand to a client that only attaches things.
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.

Dependencies

No dependencies set.

Reference
Postulo/postulo#270
No description provided.