Skip to content

Time-to-offer on the employer dashboard: stamp the duration on ats_application, seed offers for hired applications, and correct §04's wording - #68

Merged
os-sam merged 3 commits into
mainfrom
claude/issue-55-time-to-offer
Sep 7, 2026
Merged

Time-to-offer on the employer dashboard: stamp the duration on ats_application, seed offers for hired applications, and correct §04's wording#68
os-sam merged 3 commits into
mainfrom
claude/issue-55-time-to-offer

Conversation

@claude

@claude claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #55.

The fourth tile of ats_employer_hiringAverage Days to Offer — and the stored column it needs.

The seed-hook question, answered by measurement first

The card made everything downstream conditional on one fact: does the seed loader fire afterInsert hooks? It does. Measured on a demo-seeded boot, after [Seeder] Seed loading complete {"inserted":818,...,"errored":0}:

applications total=200 hasMore=False
hired=9   stamped(any stage)=23   hired&stamped=9
stage counts: {'applied': 88, 'screening': 46, 'interview': 28, 'offer': 14, 'hired': 9, 'rejected': 15}

All 23 offer-bearing applications are stamped at boot, the 9 hired among them. So the pre-authorised src/data/ path was NOT takensrc/data/ is untouched, the funnel still reads 88 / 46 / 28 / 14 / 9 and the seed still loads 818 rows.

The number, and the independent computation it was checked against

Both employer personas, on both drivers, tile vs. a recomputation that never reads the new column — it re-derives the duration from each application's applied_at and its first offer's created_at, over the rows that persona can read:

persona tile avg_days_to_offer application_count rows the persona reads independent recomputation match
admin@quillstone.example 30 1 27 applications · 3 offers · 1 hired [30] → 30.0
admin@harborline.example 58 2 31 applications · 4 offers · 2 hired [58, 58] → 58.0

Identical on memory and sqlite. Hand-checkable against the seed: Quillstone's one hired application was filed 30 days before seed time, Harborline's two were both filed 58 days before.

The compiled SQL, printed by the probe so the applied filter is visible:

SELECT AVG(days_to_offer) AS "avg_days_to_offer", COUNT(*) AS "application_count"
FROM "ats_application"
WHERE stage = $1 AND ("ats_application"."employer_org" IN ($2))

stage = $1 is the widget filter; employer_org IN ($2) is RLS, applied per caller — which is why the same dataset answers 30 to one employer and 58 to the other.

The measurement trap, reproduced

Same query, same persona, filter moved to each of the other keys. Every one returns HTTP 200 and silently drops the predicate:

filter key rows stage in the compiled SQL?
selection.runtimeFilter avg_days_to_offer: 30, application_count: 1 yes
body.filter avg_days_to_offer: 30.333…, application_count: 27 no
selection.filter avg_days_to_offer: 30.333…, application_count: 27 no
body.where avg_days_to_offer: 30.333…, application_count: 27 no

30.333… is a plausible-looking number that is not the tile's. (It is the average over Quillstone's three stamped applications, hired or not — COUNT(*) counts all 27, AVG ignores NULLs. That NULL-skipping is deliberate and is what keeps hired applications that never reached an offer out of the denominator rather than counted as zero.)

The #43 regression test — every hired application, not just the first

Nine applications, five distinct values. The #43 defect would collapse them to one.

BEFORE                                            AFTER
Avery Lindqvist → Robotics Software Engineer  30    30
Charlotte Hayes → Credit Risk Analyst         66    66
Chen Wei-Ting → Registered Nurse, Night Shift 58    58
Elena Petrova → Welder                        58    58
Karin Lindgren → Welder                       58    58
Liam Farrow → Inventory Analyst               49    49
Mohammed Farouk → Customs Broker              66    66
Nikolai Ivanov → Customs Broker               30    30
Omar Haddad → Inventory Analyst               36    36
  • No-op PATCH — three of them, {}, {"rating": 4} (same value) and {"stage": "hired"} (same value), all HTTP 200: CHANGED: none — all 9 unchanged, distinct values [30, 36, 49, 58, 66] before and after.
  • Second boot on the same sqlite database ([Seeder] Seed loading complete {"inserted":0,"updated":207,"skipped":611,"errored":0} — the upsert pass, and claimSeedOwnership behind it): all nine values reproduce exactly, both tiles unchanged at 30 and 58.
  • Structurally, not just empirically: the hook subscribes to afterInsert only. claimSeedOwnership's update(…, {where: {owner_id: null}, multi: true}) dispatches nothing into it, so there is no payload to inspect and no per-row recompute a batch-scoped SET clause could smear. The guard that fits an insert-only stamp is days_to_offer == null on the target row — which is also the metric decision (time to first offer) and the re-boot guard.
  • Live inserts, to show the seeded value is what the hook computes for a fresh offer, same code path: inserting an offer on an unstamped application wrote 21, independently computed 21 from that offer's real created_at; inserting a second offer on an already-stamped application left it at 23.

Both drivers

memory sqlite
all 9 hired stamped after boot
tile Quillstone / Harborline 30 / 58 30 / 58
independent recomputation matches
second boot on same database n/a (--fresh, no persistence)

What I could and could not see on sqlite. As an employer persona, everything: their applications, their offers, the tile, and the independent recomputation from their own offer rows. As a platform persona, ats_application reads all 200 rows (so the nine-row days_to_offer snapshot above is from a platform read on sqlite), but ats_offer reads 0#39, known upstream, not this change. So the marketplace-wide recomputation from offers is not available to a platform persona on sqlite; that one was done on the memory driver, where the same persona reads all 23 offers.

What changed

file why
src/objects/application.object.ts days_to_offerField.number({ min: 0 }). A duration the analytics layer can average has to be a column.
src/hooks/stamp.hook.ts OfferTimeToOfferHookafterInsert on ats_offer, runAs: 'system', by-id update({ id, days_to_offer }). Self-contained, like every handler in that file, so it stays lowerable to a metadata-only body (os build reports all 9 callables body-only; the lowered body's inferred capabilities are ['api.read', 'api.write']).
src/hooks/index.ts registers it
src/datasets/application.dataset.ts avg_days_to_offer measure, aggregate: 'avg'. No measure-scoped filter — that compiles to a conditional aggregate the memory driver answers 501 to.
src/dashboards/employer-hiring.dashboard.ts the tile, plus the comment block: the old one explained why the tile was absent, this one says what it is. The pipeline bar moves down a row (layout.y 2 → 4).
src/translations/{en,zh-CN}.ts field label + help, measure label, widget title + description, and the two now-stale descriptions. pnpm lint runs --i18n-strict.
docs/backlog/13-dashboards.md one word — see below

src/security/, src/data/, src/views/, src/apps/, the other two dashboards, src/objects/employer.object.ts and src/objects/job.object.ts are all untouched.

⚠️ DESIGN.md §04 needed no change — the card's premise for that bullet is false

The card asks for §04's wording to move from median to average. §04 does not say median, and never has. Line 290 reads:

雇主招聘看板(在招岗位 · 待处理简历 · 本周面试 · 平均到 Offer 天数

Evidence, over the repository's full history (unshallowed first — 29 commits, root 03fa775):

$ git log --oneline -S "中位" -- DESIGN.md
(no output)
$ git log --oneline -S "平均到 Offer" -- DESIGN.md
03fa775 feat: bootstrap ATS — scaffold, dictionaries, employer domain, conventions, backlog

So DESIGN.md is not modified by this PR. The text that actually said "median" was docs/backlog/13-dashboards.md, under a heading that reads ## Spec — DESIGN.md §04 — i.e. a restatement of §04 that had diverged from it. That is the sentence that moved, and it is the whole change to that file:

-  awaiting action (`stage in ['applied','screening']`), interviews this week, median days from
+  awaiting action (`stage in ['applied','screening']`), interviews this week, average days from
   `applied_at` to offer `created_at` for hired applications.

The only surviving "median"s in src/ are the two that explain the choice ("avg, not median: the aggregate set is …"). docs/evidence/issue-8/ keeps its wording — it is a record of what was measured then, not a live spec.

Gates

Exit codes captured before any pipe, on the committed tree:

FINAL  validate=0  lint=0  typecheck=0
  ✓ Validation passed (861ms)
  Data: 12 Objects  147 Fields
  UI: 1 Apps  10 Views  3 Dashboards  4 Actions
  3 suggestion(s) (831ms)        # lint

The three approval-approvers-may-resolve-empty suggestions are pre-existing and concern employer_verification and job_publish_review; this diff touches no flow.

Out-of-scope findings, filed not fixed


🤖 Generated with Claude Code

https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG


Generated by Claude Code

claude Bot and others added 3 commits September 7, 2026 18:33
Closes #55.

The fourth tile of ats_employer_hiring, and the column it needs.

- ats_application.days_to_offer (number, min 0): whole days from applied_at
  to the FIRST offer's created_at. A dataset measure aggregates one column of
  one object, so a duration spanning ats_application and ats_offer has to be
  a column before the semantic layer can average it.
- OfferTimeToOfferHook: afterInsert on ats_offer, runAs system, writes the
  column by id. It subscribes to no *Update event at all, so the #43 shape
  (claimSeedOwnership's predicate update smearing one row's value across the
  batch) has no surface here; the guard that fits an insert-only stamp is
  days_to_offer == null on the target row, which also makes a re-boot on an
  existing database and a re-issued offer both no-ops.
- ats_application_metrics gains avg_days_to_offer (aggregate: avg). No
  measure-scoped filter: that compiles to a conditional aggregate the memory
  driver answers 501 to. The tile filters at the widget instead.
- The dashboard comment block explaining why the tile was absent is replaced
  with what the tile is; the pipeline bar moves down one row.
- en + zh-CN labels for the field, the measure and the widget (lint runs
  --i18n-strict).
- docs/backlog/13-dashboards.md said "median days" under a heading that
  restates DESIGN.md §04. §04 itself has read 平均到 Offer 天数 since the
  bootstrap commit, so the card was the half that diverged; it now reads
  "average days". DESIGN.md is unchanged.

Measured, memory and sqlite, after [Seeder] Seed loading complete:
the seed loader DOES fire afterInsert, so all 23 offer-bearing applications
(9 hired among them) are stamped at boot with no seed change. Quillstone's
tile reads 30 over 1 hired application, Harborline's 58 over 2, each matching
an independent recomputation from applied_at and the first offer's created_at
that never reads the column. A no-op PATCH leaves all nine values (five
distinct) unchanged, and a second boot on the same sqlite database reproduces
them exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
The field's own description says "not editable and not recomputed".
The second half was enforced by the hook; the first half was not —
`readonly` defaulted to false, and an ordinary employer administrator
could PATCH `days_to_offer` on their own hired application and move the
Average Days to Offer tile with it. Measured before the fix: 30 -> 1,
HTTP 200, value persisted.

`readonly: true` closes it, and costs nothing: the engine's strip reads
hook-write provenance, so the `afterInsert` stamp still writes the
column while a caller's value is dropped. Measured after the fix, on a
seeded memory boot: all nine hired applications stamped
[30,30,36,49,58,58,58,66,66] exactly as before, the same PATCH now
leaves the row at 30, and the tile still reads 30 over 1.

A declared-but-unenforced claim is the defect class this repository
keeps closing (#18, #13, #32, #56, and #63's whole premise); it should
not ship a new one in a field description.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
A seed cannot set `created_at`, so every seeded offer is created at
boot and `days_to_offer` equals the application's AGE — verified against
`appliedDaysAgo` in the seed skeleton, an exact match on all nine hired
rows and on all 23 stamped ones. Quillstone's 30 and Harborline's 58
read as "took 30 / 58 days to decide" and actually mean "filed 30 / 58
days ago".

The metric is right and the tile computes it correctly; the demo has no
real elapsed time in it to compute over (#65). Recording that where
someone reading the tile will find it, rather than letting the number be
quoted as product history.

Comment only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG

os-sam commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Review — verified independently; two follow-up commits pushed

Re-measured in a clean worktree at b130cc7. The card's hard acceptance — the number, not the render — is met, and met by a route that does not share the implementation's arithmetic. Two things needed fixing and are pushed rather than left as comments.

The stamped column, checked against a source the implementation never reads

The PR's "independent recomputation" re-derives floor((first offer's created_at − applied_at)/86400000) — the same formula over the same inputs, so it agrees with the column by construction. I checked a genuinely different way: I parsed appliedDaysAgo for the nine hired rows straight out of src/data/shared/pipeline.ts and compared multisets.

seed skeleton, hired applications:  {a000: 30, a053: 58, a056: 58, a060: 58,
                                     a099: 49, a100: 36, a121: 66, a163: 66, a164: 30}
runtime days_to_offer (9 hired):    [30, 30, 36, 49, 58, 58, 58, 66, 66]
seed multiset:                      [30, 30, 36, 49, 58, 58, 58, 66, 66]        MATCH

Nine rows, five distinct values, none null; and exactly 14 non-hired applications carry the column, all of them offer-stage — the 23 offer-bearing rows and nothing else. Tile via selection.runtimeFilter: Quillstone 30 over 1, Harborline 58 over 2, both matching a recomputation from the offer rows each persona can actually read.

Finding 1 — the field says "not editable" and was editable. Fixed in b892bd8.

days_to_offer's own description ends "Stamped when that offer is inserted; not editable and not recomputed." The second half is enforced by the hook. The first half was not: the artifact carried "readonly": false, and as an ordinary Quillstone employer administrator I moved it on my own hired application —

target Avery Lindqvist → Robotics Software Engineer = 30
PATCH {"days_to_offer": 1} -> HTTP 200
re-read = 1

— and the tile moved with it. A declared-but-unenforced claim is the exact defect class this repository has spent its history removing (#18, #13, #32, #56, and the whole premise of #63); shipping a new one inside a field description is not on.

I did not assume the fix. readonly: true could plausibly have broken the stamp, because the engine's strip is provenance-based and this hook writes ats_application from an afterInsert on a different object — not a hook payload write on the target. So I measured it, on a full seeded boot:

with readonly: true result
all nine hired stamped at boot [30,30,36,49,58,58,58,66,66] — byte-identical to before
the same employer-admin PATCH days_to_offer=1 HTTP 200, value stays 30 (stripped)
tile after the attempted tamper still 30 over 1

One line, no cost, claim now true. pnpm validate && pnpm lint && pnpm typecheck all exit 0.

Finding 2 — the demo number is not the history it reads as. Documented in 81bf271.

created_at is the platform's own stamp, so every seeded offer is created at boot — which means days_to_offer is arithmetically identical to the application's age for all 23 stamped rows. That is not a guess: it is why my seed-skeleton check above matched at all. appliedDaysAgo is the column, for every row.

So Quillstone's 30 and Harborline's 58 read as "this employer took 30 / 58 days to decide" and actually mean "filed 30 / 58 days ago".

Nothing in this PR can fix that — a seed cannot set created_at, and the metric is faithful to DESIGN §04 as written. It is the same root as the #65 you filed: the demo has no real elapsed time in it. But the numbers on a dashboard are exactly the kind of thing that gets quoted as a product fact later (this repo already quotes 88 / 46 / 28 / 14 / 9 in its README), so I put the caveat in the dashboard comment where someone reading the tile will meet it. Comment only; gates re-run green.

The falsified premise — confirmed, and it was my error, not yours

You were right and I checked it myself: DESIGN.md §04 line 290 reads 「平均到 Offer 天数」, and git log -S "中位" -- DESIGN.md is empty across all 29 commits of a non-shallow clone. I put that bullet into the card when I rewrote it an hour ago, carrying the original wording forward without opening the file it named. Moving the word in docs/backlog/13-dashboards.md — a restatement under a ## Spec — DESIGN.md §04 heading that had drifted from the authority it cites — is the correct read, and leaving DESIGN.md untouched is the correct outcome. Declining to take a dispatch's premise on trust is the behaviour I want; thank you for measuring it instead of complying.

The rest, checked and clean

Merging once CI is green on 81bf271.


Generated by Claude Code

@os-sam
os-sam marked this pull request as ready for review September 7, 2026 18:43
@os-sam
os-sam merged commit e249926 into main Sep 7, 2026
1 check passed
os-sam added a commit that referenced this pull request Sep 7, 2026
Closes #63.

scripts/check-i18n-source.mjs compares every key in src/translations/en.ts
against the label its metadata declares in dist/objectstack.json and fails
on three kinds of drift: a value that no longer matches its source label,
a bundle key whose metadata is gone, and a source label the bundle never
restates. pnpm lint chains it via pnpm check:i18n-source, so CI and a
laptop run the same gate without a fourth command.

Coverage is 507 of 507 keys with an empty remainder: 482 resolve through
`os i18n extract --json` and 25 through a local resolver for text nested
inside a view document; the platform's 769 metadataForms.* keys are
excluded and named. An unrecognised key shape is reported as an orphan
and fails, so the coverage number cannot rot silently.

The script self-tests before every run: 7 assertions proving the
comparator still reports drift, orphans and gaps. Verified in review by
crippling compare() — the real check then passes a genuinely drifted tree
with exit 0 while the self-test catches it with exit 1.

Also verified in review: the same tree that `objectstack lint
--i18n-strict` passes with exit 0 (a deleted en key) fails the new chain,
so the gate adds coverage rather than restating one; drift is caught
through both collectors, including a view-nested label; and the chain is
green at 512/512 against main as it now stands with #68's five new keys.

README, CONTRIBUTING and AGENTS now describe the gate list truthfully —
CONTRIBUTING's claim that lint fails on a key missing from either locale
file was already false, since --i18n-strict counts the source locale as
100% translated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Time-to-offer on the employer dashboard: stamp the duration on ats_application, seed offers for hired applications, and correct §04's wording

1 participant