Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 86 additions & 7 deletions src/hooks/stamp.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,72 @@ export const JobStampHook = defineHook({
* payload: letting a caller supply them would let them file an application into
* another employer's scope. A payload that names them is therefore a reason to
* re-derive, never a value to keep.
*
* ## What counts as activity on an application
*
* `last_activity_at` is the column the seeker's "My Applications" timeline
* shows (DESIGN.md §04) and the only recency signal the model offers. It used
* to be assigned unconditionally, on both events, outside every guard, so the
* seed's authored `activityDaysAgo` never reached the database: measured on a
* seeded sqlite boot, all 200 demo applications ended the boot carrying ONE
* identical instant. Identical rather than merely inside one second because the
* boot's LAST write to them is `claimSeedOwnership`'s `multi: true` claim, and a
* predicate update sends ONE `SET` clause: whatever the handler wrote for the
* last matched row lands on every matched row (#43's shape, ADR-0058 Addendum
* II D3). #65 read the same table as a 0.7s window on its author's boot — either
* way the column is boot time, and either way the write that produces it is on
* the UPDATE path, which is why guarding only the insert would have fixed the
* first boot and left the second: the claim pass, and the seed's own re-boot
* upsert, wipe restored history on boot 2 of a persistent database (measured:
* before this guard, 200 of 200 values changed between two boots of one file).
* So the question this hook has to answer is not "insert or update" but WHICH
* PAYLOADS ARE ACTIVITY. Three clauses, in order:
*
* 1. A payload that names `last_activity_at` with a value is authored data —
* seed history, an import, a backfill — and wins on both events. This is
* what carries the demo's history through the re-boot upsert. The test is
* `== null`, so a client that sends the key as `null` still gets a stamp:
* the column is a stamp, not a nullable note.
* 2. Filing IS activity — an insert with no authored value stamps now, the
* same shape `applied_at` has two lines above.
* 3. An update stamps only when its payload names at least one field of the
* application itself: a stage move, a rating, a rejection reason, a
* re-pointed job. The exceptions are enumerated because each is a write
* the PLATFORM makes ABOUT the row rather than a person acting ON it:
* - `owner_id` — plugin-security's boot-time ownership claim. This is
* the one that matters most: it is a `multi: true` predicate write
* over every unowned row on EVERY boot.
* - `days_to_offer` and `interview_count` — the object's two DERIVED
* columns, and neither is authorable: `days_to_offer` is
* `readonly: true`, stamped once by `ats_offer_time_to_offer`;
* `interview_count` is a `Field.summary` roll-up the engine recomputes
* when an interview row lands. Each restates a fact whose own
* timestamp lives on the OTHER row — the offer's `created_at`, the
* interview's `scheduled_at` — and the recruiter's act that produced
* it (moving the application to `stage: offer` / `stage: interview`)
* is a payload that DOES name a field of the application and does
* stamp. Counting the derived writes as activity re-dates the 23
* offer-bearing and 28 interview-bearing demo applications to boot
* time on the first boot and not on the next (both parent rows are
* inserted once and upserted after) — a demo dataset that changes
* shape per boot.
* - `id`, `created_at`, `created_by`, `updated_at`, `updated_by` — the
* engine's own columns. `updated_at` is the audit stamp of the last
* write; "someone acted on this application" is a different fact, and
* this field is the one that carries it.
*
* Measured on a fresh seeded sqlite boot (cli 17.3.0): the update path receives
* exactly three payload shapes, 200 x `{owner_id, updated_at}` (the claim),
* 28 x `{id, interview_count, updated_at}` and 23 x `{id, days_to_offer,
* updated_at}` — every one of them a write the platform makes about the row,
* and not one of them a person acting on it.
*
* A deny-list rather than an allow-list, deliberately. Activity is "a write to
* this row" minus a short, nameable set of platform writes — the engine's own
* columns plus this object's non-authorable derived ones — so a field added to
* the object tomorrow counts as activity without anyone remembering to list it;
* the failure direction is one stamp too many, never a timeline frozen at boot
* again.
*/
export const ApplicationStampHook = defineHook({
name: 'ats_application_stamp',
Expand Down Expand Up @@ -199,7 +265,17 @@ export const ApplicationStampHook = defineHook({
if (inserting && input.applied_at == null) {
input.applied_at = new Date().toISOString();
}
input.last_activity_at = new Date().toISOString();

// `last_activity_at` — the rule and its reasons are in the header section
// "What counts as activity on an application". In short: an authored value
// wins; filing is activity; an update is activity when its payload names
// anything other than the platform's own bookkeeping columns or the
// derived metric a sibling hook stamps.
if (input.last_activity_at == null) {
const bookkeeping = ['id', 'owner_id', 'created_at', 'created_by', 'updated_at', 'updated_by', 'days_to_offer', 'interview_count'];
const activity = Object.keys(input).some((k) => input[k] !== undefined && !bookkeeping.includes(k));
if (inserting || activity) input.last_activity_at = new Date().toISOString();
}
},
});

Expand Down Expand Up @@ -304,12 +380,15 @@ export const OfferStampHook = defineHook({
* of the payload), never a predicate write. It goes down the engine's normal
* path, so `ats_application`'s own `beforeUpdate` stamps run on it:
* `ApplicationStampHook` sees a payload naming neither a source field nor a
* derived one and re-derives nothing, but it does refresh `last_activity_at`,
* as it does for every write to an application. That costs nothing here —
* measured on a seeded sqlite boot, all 200 applications already carry a
* `last_activity_at` inside the same second, because that assignment is
* unconditional on `beforeInsert` too — and writing an offer IS activity on the
* application, so it is the right answer rather than a side effect to suppress.
* derived one and re-derives nothing — and, since #65, does not refresh
* `last_activity_at` either: `days_to_offer` is named in that hook's
* bookkeeping list on purpose. This write restates a duration that ended at
* THIS offer's `created_at`, the offer row carries that timestamp, and the
* recruiter's own act — moving the application to `stage: offer` — is the write
* that counts as activity. The earlier reading here, that the refresh "costs
* nothing", held only while every application already read boot time; against a
* seeded database it would re-date the 23 offer-bearing applications on boot 1
* and not on boot 2. See "What counts as activity on an application" above.
*
* `runAs: 'system'`: a recruiter extending an offer is not necessarily allowed
* to edit that application row, and a cross-object write through `ctx.api` is
Expand Down
Loading