From cefec8970e4704757ae433fcba4ad43df8e2dbe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:07:10 +0000 Subject: [PATCH 1/3] fix(hooks): let a seeded last_activity_at survive the application stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ApplicationStampHook` assigned `last_activity_at = now()` outside every guard, on `beforeInsert` and `beforeUpdate` alike, so the seed's authored `activityDaysAgo` never reached the database: all 200 demo applications read one identical boot-time instant — one instant, not a spread, because `claimSeedOwnership`'s `multi: true` claim applies a single SET clause to every matched row. Guard the assignment on both events. An authored value wins; a filing is activity; an update is activity when its payload names a field of the application rather than one of the platform's own bookkeeping columns (`owner_id`, the engine's audit columns) or the `days_to_offer` metric a sibling hook stamps. The chosen rule and every exception is written down in the hook's header, and the stale paragraph in `OfferTimeToOfferHook` that described the unconditional refresh is corrected there. Guarding only the insert would have been a boot-1 fix: the ownership claim and the seed's own re-boot upsert wipe restored history on boot 2 of a persistent database. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- src/hooks/stamp.hook.ts | 74 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/src/hooks/stamp.hook.ts b/src/hooks/stamp.hook.ts index 5d4288f..219754d 100644 --- a/src/hooks/stamp.hook.ts +++ b/src/hooks/stamp.hook.ts @@ -157,6 +157,53 @@ 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 seeker timeline's clock (DESIGN.md §04) and the one + * recency signal a view can sort on. It used to be assigned unconditionally, on + * both events, outside every guard. Measured on a seeded sqlite boot, that had + * two consequences: all 200 demo applications carried ONE identical instant — + * one instant rather than a 0.7s spread, because `claimSeedOwnership`'s + * `multi: true` claim applies a single `SET` clause to every matched row (#43's + * shape, ADR-0058 Addendum II D3) — and the seed's authored `activityDaysAgo` + * never reached the database at all. 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, would wipe the restored history on boot 2 of a persistent + * database. 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` — the derived metric `ats_offer_time_to_offer` + * stamps once. It restates a duration that ends at the OFFER's own + * `created_at`, which the offer row already carries, and a recruiter + * who extends an offer also moves the application to `stage: offer` — + * that write is the activity. Counting the metric write as activity + * would re-date the 23 offer-bearing demo applications to boot time on + * boot 1 and not on boot 2 (the offer is inserted once, 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. + * + * A deny-list rather than an allow-list, deliberately. Activity is "a write to + * this row" minus a short, nameable set of platform writes, so a field added to + * the object tomorrow counts 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', @@ -199,7 +246,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', 'days_to_offer', 'created_at', 'created_by', 'updated_at', 'updated_by']; + const activity = Object.keys(input).some((k) => input[k] !== undefined && !bookkeeping.includes(k)); + if (inserting || activity) input.last_activity_at = new Date().toISOString(); + } }, }); @@ -304,12 +361,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 From ac9d8819d51c2c818aba4d75837385adaa318555 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:10:59 +0000 Subject: [PATCH 2/3] fix(hooks): count the interview roll-up as bookkeeping, not activity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `interview_count` is a `Field.summary` the engine recomputes when an interview row lands; measured on a seeded boot it reaches the update path as `{id, interview_count, updated_at}` for the 28 interview-bearing applications. Like `days_to_offer` it restates a fact whose timestamp lives on the other row, and counting it re-dated those 28 rows to boot time on boot 1 and not on the next — the same per-boot instability the metric exemption avoids. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- src/hooks/stamp.hook.ts | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/src/hooks/stamp.hook.ts b/src/hooks/stamp.hook.ts index 219754d..565bf2a 100644 --- a/src/hooks/stamp.hook.ts +++ b/src/hooks/stamp.hook.ts @@ -187,23 +187,37 @@ export const JobStampHook = defineHook({ * - `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` — the derived metric `ats_offer_time_to_offer` - * stamps once. It restates a duration that ends at the OFFER's own - * `created_at`, which the offer row already carries, and a recruiter - * who extends an offer also moves the application to `stage: offer` — - * that write is the activity. Counting the metric write as activity - * would re-date the 23 offer-bearing demo applications to boot time on - * boot 1 and not on boot 2 (the offer is inserted once, upserted - * after): a demo dataset that changes shape per 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, so a field added to - * the object tomorrow counts without anyone remembering to list it; the failure - * direction is one stamp too many, never a timeline frozen at boot again. + * 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', @@ -253,7 +267,7 @@ export const ApplicationStampHook = defineHook({ // 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', 'days_to_offer', 'created_at', 'created_by', 'updated_at', 'updated_by']; + 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(); } From 1e4c7cc4edb0400a289d31f24743150b390673bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 19:18:32 +0000 Subject: [PATCH 3/3] docs(hooks): state the measured claim-pass shape, not an inferred spread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header now says what this branch measured — 200 identical values at the end of boot 1, the claim pass as the boot's last write to the row, and the 200-of-200 change across two boots before the guard — and records that #65's own read of the same table was a 0.7s window, which does not change either conclusion. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- src/hooks/stamp.hook.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/hooks/stamp.hook.ts b/src/hooks/stamp.hook.ts index 565bf2a..af9c7d4 100644 --- a/src/hooks/stamp.hook.ts +++ b/src/hooks/stamp.hook.ts @@ -160,18 +160,23 @@ export const JobStampHook = defineHook({ * * ## What counts as activity on an application * - * `last_activity_at` is the seeker timeline's clock (DESIGN.md §04) and the one - * recency signal a view can sort on. It used to be assigned unconditionally, on - * both events, outside every guard. Measured on a seeded sqlite boot, that had - * two consequences: all 200 demo applications carried ONE identical instant — - * one instant rather than a 0.7s spread, because `claimSeedOwnership`'s - * `multi: true` claim applies a single `SET` clause to every matched row (#43's - * shape, ADR-0058 Addendum II D3) — and the seed's authored `activityDaysAgo` - * never reached the database at all. 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, would wipe the restored history on boot 2 of a persistent - * database. So the question this hook has to answer is not "insert or update" - * but WHICH PAYLOADS ARE ACTIVITY. Three clauses, in order: + * `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