diff --git a/docs/evidence/issue-67/01-after-employers-pending-nav-slice.png b/docs/evidence/issue-67/01-after-employers-pending-nav-slice.png new file mode 100644 index 0000000..506a926 Binary files /dev/null and b/docs/evidence/issue-67/01-after-employers-pending-nav-slice.png differ diff --git a/docs/evidence/issue-67/02-after-authored-view-employer-all.png b/docs/evidence/issue-67/02-after-authored-view-employer-all.png new file mode 100644 index 0000000..de2c55f Binary files /dev/null and b/docs/evidence/issue-67/02-after-authored-view-employer-all.png differ diff --git a/docs/evidence/issue-67/03-after-employer-record-header.png b/docs/evidence/issue-67/03-after-employer-record-header.png new file mode 100644 index 0000000..aff77e2 Binary files /dev/null and b/docs/evidence/issue-67/03-after-employer-record-header.png differ diff --git a/docs/evidence/issue-67/04-after-sqlite-boot2-employers-pending.png b/docs/evidence/issue-67/04-after-sqlite-boot2-employers-pending.png new file mode 100644 index 0000000..5e45d95 Binary files /dev/null and b/docs/evidence/issue-67/04-after-sqlite-boot2-employers-pending.png differ diff --git a/docs/evidence/issue-67/README.md b/docs/evidence/issue-67/README.md new file mode 100644 index 0000000..2b6339e --- /dev/null +++ b/docs/evidence/issue-67/README.md @@ -0,0 +1,103 @@ +# Evidence — issue #67 (`Field.user` columns render the raw user id in every grid) + +Captured on 2026-09-08 in one container, on branch `claude/issue-67-employer-contact`, against +`OS_PLATFORM_OWNER_EMAIL=admin@objectos.ai npx objectstack dev -p 4671 --log-level info` with the +driver named per run, after `[Seeder] Seed loading complete`. Every count below was read from the +top-level `records` / `total` of a REST list response with `hasMore == false` and +`records.length == total` asserted first. + +| Persona | Sign-in | Role here | +|:--|:--|:--| +| Platform administrator | `admin@platform.example` / `demo1234` | the reviewer this card is about | +| Platform operations | `ops@platform.example` / `demo1234` | works the pending queue | +| Job seeker | `candidate01@mail.example` / `demo1234` | the audience the mirror must NOT reach | +| Platform owner | `admin@objectos.ai` / `admin123` | holds no ATS position; used only as the independent reader that sees all 113 `sys_user` rows | + +## The shots + +| File | Driver | URL | What it shows | +|:--|:--|:--|:--| +| `01-after-employers-pending-nav-slice.png` | memory | `/_console/apps/ats/ats_employer/data?filter[verification_status]=pending` | The review queue, reached by clicking the **Employers Pending** nav entry. 2 records, `Primary Contact Name` = `Zoe Hamilton`, `Samir Khoury`. Zero `usr_ats_*` strings on screen. | +| `02-after-authored-view-employer-all.png` | memory | `/_console/apps/ats/ats_employer` | The authored `ats_employer.all` view, reached by clicking **Employers**. 12 records, all 12 contact names rendered, zero `usr_ats_*` strings on screen. | +| `03-after-employer-record-header.png` | memory | `/_console/apps/ats/ats_employer/record/…` (Pixelforge Studios) | The record page. Header strip reads `Primary Contact Name: Zoe Ha…` — the shell truncates a 5-item strip, the same behaviour `docs/evidence/issue-33/` recorded for `Pendi…` / `Full-ti…`. **The Details tab still shows `PRIMARY CONTACT usr_ats_pixelforge_admin`**: that is the `owner` pointer, deliberately unchanged and deliberately not replaced — it is what the F1 approval notifies and what an admin edits. This shot doubles as the "before" for the renderer: a `Field.user` still prints its raw value wherever the pointer itself is shown. | +| `04-after-sqlite-boot2-employers-pending.png` | sqlite, 2nd boot of one file | same as `01-…` | The same queue after the second boot of a persistent database — the boot that runs `claimSeedOwnership`'s `multi: true` pass and the seed's re-boot upsert. Names intact. | + +The "before" state of the two grids is this repository's own +`docs/evidence/issue-33/01-after-employers-pending-nav-slice.png` and +`12-after-authored-view-employer-all-unchanged.png`, taken with the same recipe on `62e496d`, plus +the measurement quoted in the card itself (`records[0].owner == "usr_ats_pixelforge_admin"`). It was +not re-taken here; shot `03-…` shows the unresolved pointer still rendering that way today. + +## The two premises the fix rests on, re-checked + +**1. No platform persona can read an employer-staff `sys_user` row** — so the runtime's refusal to +expand `ats_employer.owner` is correct, and the fix must not widen that read. + +| Persona | `sys_user` readable | of the 30 employer-staff rows | +|:--|--:|--:| +| platform administrator | 1 of 113 | 0 | +| platform operations | 1 of 113 | 0 | +| job seeker | 1 of 113 | 0 | +| platform owner (`admin@objectos.ai`) | 113 of 113 | 30 | + +Identical on both drivers. The set of "employer-staff" ids is not hand-written: it is the 30 `user` +values of `ats_employer_member`, read as the platform owner. Note the drift from the card's triage +comment, which measured 83 of 113 readable on `54cd69c`: the readable set is now the caller's own +row alone. The conclusion the premise carries is unchanged and stronger. + +**2. The name is already in this audience's hands** — `ats_employer_member.display_name` is a stored +mirror stamped `" · "`. + +| Persona | `ats_employer_member` readable | rows carrying a name | +|:--|--:|--:| +| platform administrator | 30 of 30 | 30 | +| platform operations | 30 of 30 | 30 | +| job seeker | **403 `PERMISSION_DENIED`** | — | + +Both drivers. The seeker line is why the job-seeker permission set seals `owner_name`: for that +audience the name would have been new information, not a restatement. + +## The projected values, cross-checked against an independent source + +`owner_name` is stamped by `ats_employer_stamp` from `sys_user.name`. It is verified here against +`ats_employer_member.display_name` — a different column, stamped by a different handler, from a +different row, seeded independently — never against itself. Both the name and the pointer are +compared, so a right name on a wrong contact would fail. + +| Employer | `ats_employer.owner_name` | `ats_employer_member.display_name` (that employer's `admin` row) | pointer vs `member.user` | +|:--|:--|:--|:--| +| Bluewater Hospitality Group | Nicolas Aubert | Nicolas Aubert · admin | `usr_ats_bluewater_admin` = same | +| Brightmarket Retail Group | Stephen Quill | Stephen Quill · admin | `usr_ats_brightmarket_admin` = same | +| Cedarbrook Care Network | Helen Mwangi | Helen Mwangi · admin | `usr_ats_cedarbrook_admin` = same | +| Harborline Manufacturing | Walter Brandt | Walter Brandt · admin | `usr_ats_harborline_admin` = same | +| Ironbridge Construction | Frank Delaney | Frank Delaney · admin | `usr_ats_ironbridge_admin` = same | +| Lumenvale Academy | Ruth Calloway | Ruth Calloway · admin | `usr_ats_lumenvale_admin` = same | +| Meridian Clinics | Samir Khoury | Samir Khoury · admin | `usr_ats_meridian_admin` = same | +| Orbit Consulting Partners | Victoria Lang | Victoria Lang · admin | `usr_ats_orbit_admin` = same | +| Pixelforge Studios | Zoe Hamilton | Zoe Hamilton · admin | `usr_ats_pixelforge_admin` = same | +| Quillstone Robotics | Margaret Ellison | Margaret Ellison · admin | `usr_ats_quillstone_admin` = same | +| Summitridge Financial | Alan Pemberton | Alan Pemberton · admin | `usr_ats_summitridge_admin` = same | +| Swiftroute Logistics | Paula Reyes | Paula Reyes · admin | `usr_ats_swiftroute_admin` = same | + +12 of 12 matched on both the name and the pointer, on memory and on sqlite, on the first boot and on +the second boot of one persistent file. + +## #43 — the stamp must not be re-derived by a payload that does not name its source + +| Write | Expected | Measured (both drivers) | +|:--|:--|:--| +| `PATCH {}` (no-op) | no stamp moves | 0 of 12 rows changed | +| `PATCH { city }` with the value it already has | no stamp moves | 0 of 12 rows changed | +| `PATCH { verification_note }` — the reviewer's own write | no stamp moves | 0 of 12 rows changed | +| `PATCH { owner_name: "HACKED VALUE" }` | re-derived from `owner`, nothing else touched | 0 of 12 rows changed (the tampered value did not survive) | +| `PATCH { owner: }` | that row follows, alone | 1 of 12 rows changed, to `Victoria Lang` | +| `PATCH { owner: null }` | the mirror empties | `owner = null`, `owner_name = null` | +| second boot of one sqlite file (`claimSeedOwnership` + the seed's re-boot upsert) | all 12 values reproduce | 12 of 12 `(name, owner, owner_name)` triples identical to boot 1; 12 distinct values, so no single `SET` clause was broadcast | + +Boot 2's seeder line, for the record: `[Seeder] Seed loading complete {"inserted":0,"updated":7,"skipped":811,"errored":0}`. + +## Seed shape — unchanged + +818 rows inserted on a fresh boot (`errored: 0`); `ats_application` 200 with the funnel +`applied 88 · screening 46 · interview 28 · offer 14 · hired 9` (+ `rejected 15`); `ats_candidate` 80; +`ats_employer` 12; `ats_employer_member` 30. Same on both drivers and on both sqlite boots. diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 401a069..924291e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,4 +1,5 @@ import { + EmployerStampHook, EmployerMemberStampHook, JobStampHook, ApplicationStampHook, @@ -10,6 +11,7 @@ import { import { InquiryStampHook, InquiryConvertHook } from './inquiry.hook.js'; export const allHooks = [ + EmployerStampHook, EmployerMemberStampHook, JobStampHook, ApplicationStampHook, diff --git a/src/hooks/stamp.hook.ts b/src/hooks/stamp.hook.ts index af9c7d4..0a3a025 100644 --- a/src/hooks/stamp.hook.ts +++ b/src/hooks/stamp.hook.ts @@ -85,6 +85,92 @@ import { defineHook, type HookContext } from '@objectstack/spec/data'; /** Row shape the stamps read back. Local to each handler by necessity. */ type Row = Record; +/** + * `ats_employer` — mirror the primary contact's name onto the employer row. + * + * `owner` is a `Field.user`, and a user field renders whatever the query + * returns for it. For a platform reviewer that is the bare id: the contact is + * employer-side staff, their `sys_user` row is not readable to a platform + * persona, and the runtime refuses the expansion rather than leaking it — so + * the review queue's "who do I contact" column reads `usr_ats_*` (#67). + * + * The name is resolved HERE, once, on the write, under `runAs: 'system'`, for + * the same reason every other stamp in this file is: the caller who writes the + * employer row is not necessarily allowed to read the user row the value comes + * from, and a caller-scoped read would resolve to nothing. What lands in the + * column is the NAME only — see the field's own comment in + * `employer.object.ts` for why that bound is the whole argument. + * + * ## The one residual, stated + * + * A later rename of the `sys_user` row does not reach back into this column; + * only a write that names `owner` (or `owner_name`) re-derives it. That is the + * identical residual `ats_employer_member.display_name` already carries, and it + * is the price of a stored mirror — the alternative, recomputing on every read, + * is what a formula would do and CEL cannot read a user's name at all. A + * REASSIGNMENT is not stale: re-pointing `owner` is a payload that names it, + * and so is CLEARING it — `owner: null` empties the mirror rather than leaving + * a name behind (the `??` that used to read the pointer here resurrected the + * old one: measured, `PATCH {"owner": null}` left the previous name in place). + * + * ## Why the guard, not an unconditional re-derive (#43) + * + * plugin-security's `claimSeedOwnership` runs `update(ats_employer, + * { owner_id }, { where: { owner_id: null }, multi: true })` on every boot, and + * a predicate update sends ONE `SET` clause for all matched rows: whatever this + * handler wrote for the last row would land on all twelve. Its payload names + * `owner_id` — the injected ownership anchor — and NOT `owner`, so the guard + * below returns before anything is computed and the twelve stamps survive the + * claim pass untouched. Same reason a no-op PATCH leaves the column alone. The + * shape is the file header's rule, verbatim: on insert stamp; on update only + * when the payload names a source field or the derived field itself (which + * keeps the anti-tamper property — writing `owner_name` directly re-derives it + * from `owner` rather than storing what the caller sent). + */ +export const EmployerStampHook = defineHook({ + name: 'ats_employer_stamp', + object: 'ats_employer', + events: ['beforeInsert', 'beforeUpdate'], + priority: 100, + runAs: 'system', + description: "Stamps the employer's primary contact name from the contact's user record.", + handler: async (ctx: HookContext) => { + const api = ctx.api; + if (!api) throw new Error('ats_employer_stamp: ctx.api is unavailable, the primary contact cannot be resolved'); + const input = ctx.input as Row; + const prev = (ctx.previous ?? {}) as Row; + const inserting = ctx.event === 'beforeInsert'; + if (!inserting && !['owner', 'owner_name'].some((k) => input[k] !== undefined)) return; + + // Read `previous` only when the payload does not name `owner` AT ALL: `??` + // treats an explicit `owner: null` as absent and would re-derive the name + // of the contact just removed. + const ownerRef = input.owner !== undefined ? input.owner : prev.owner; + if (typeof ownerRef !== 'string' || ownerRef === '') { + // No contact to mirror. An update that cleared the pointer has a stale + // name to remove; an insert has nothing to clear, and writing the key + // there would only add an explicit null the column does not need. + if (!inserting) input.owner_name = null; + return; + } + // One lookup, by id. The reference IS an id by the time a handler sees it: + // the engine refuses a `Field.user` value that is not an existing + // `sys_user` id (`VALIDATION_FAILED` / `reference_not_found`, measured on + // both an e-mail and a bogus id), and the seeder resolves its `externalId` + // reference before the hook runs — which is why the member stamp below, + // whose lookup has only ever been by id, titles all 30 seeded rows with + // real names from a seed that addresses users by e-mail. + const user = (await api.object('sys_user').findOne({ where: { id: ownerRef } })) as Row | null; + const name = String(user?.name ?? '').trim(); + // Fallback is the reference itself, never an empty cell: an unresolvable + // contact is a data problem the reviewer should SEE, the same call + // `ats_employer_member.display_name` makes (#22). Unreachable from the API + // by the validation above; what it covers is a contact whose user row was + // deleted after the fact. + input.owner_name = name !== '' ? name : ownerRef; + }, +}); + /** * `ats_employer_member` — inherit the employer's organization, and title the * row as "USER NAME · ACCESS_LEVEL" (object description, DESIGN.md §02): the diff --git a/src/objects/employer.object.ts b/src/objects/employer.object.ts index bbdcc92..8c96a47 100644 --- a/src/objects/employer.object.ts +++ b/src/objects/employer.object.ts @@ -30,7 +30,14 @@ export const Employer = ObjectSchema.create({ // contact (#33). `verification_docs` is deliberately absent: the seed carries // it on 0 of 12 rows and a multi-file column cannot be read at a glance — // the reviewer opens it on the record. - highlightFields: ['name', 'industry', 'city', 'verification_status', 'service_tier', 'owner'], + // + // The contact slot names `owner_name`, not `owner`: a `Field.user` renders + // whatever the query returns for it, and for a platform reviewer that is the + // bare id — the contact's `sys_user` row is not readable to them, so the + // expansion is refused and the cell degrades to `usr_ats_*` (#67). The + // pointer stays authoritative (the F1 approval notifies `record.owner`); + // this slot carries the one fact a reviewer reads at a glance. + highlightFields: ['name', 'industry', 'city', 'verification_status', 'service_tier', 'owner_name'], fields: { name: Field.text({ @@ -131,6 +138,43 @@ export const Employer = ObjectSchema.create({ label: 'Primary Contact', defaultValue: 'current_user', }), + /** + * The reviewer's readable contact column — a STORED mirror of the primary + * contact's `sys_user.name`, the same shape and the same reason as + * `ats_employer_member.display_name`: CEL cannot read a user's name, so a + * formula is not an option, and a formula is not searchable either. + * + * Minimal by construction — the NAME and nothing else. That bound is what + * makes the mirror defensible rather than a way around the row-visibility + * rule it sits next to, and it holds PER AUDIENCE, not in general: + * + * platform admin / ops `ats_employer_member` is readable to both, 30 of + * 30 rows, and its `display_name` already reads + * "Margaret Ellison · admin" for every one of the + * 12 contacts (measured, both personas). For them + * this column restates a fact they already hold. + * job seeker reads the 9 verified employers and is 403 on + * `ats_employer_member` (measured). For them the + * name is NEW — the pointer they read today is an + * opaque `usr_ats_*` — so the job-seeker set seals + * this field the way it seals `verification_note` + * (`security/permission-sets.ts`). The seal is + * part of the fix, not an afterthought: without it + * a rendering repair for 2 reviewers discloses 12 + * contact names to 80 seeker accounts. + * + * A work e-mail or a phone number would fail that test for EVERY audience — + * nothing shows those to a platform reviewer today, and copying them here + * would be a new disclosure wearing a rendering fix's clothes. + * + * Not `searchable`, deliberately: search is a second, wider surface and the + * reviewer's queue is a 2-row slice they read, not search. + */ + owner_name: Field.text({ + label: 'Primary Contact Name', + maxLength: 200, + description: 'Name of the primary contact, stamped from their user record on write. A platform reviewer cannot read employer-staff user rows, so the contact pointer alone renders as an opaque id.', + }), /** CEL: may this employer publish jobs right now? */ can_publish: Field.formula({ diff --git a/src/security/permission-sets.ts b/src/security/permission-sets.ts index b8f7ea5..3bd1abe 100644 --- a/src/security/permission-sets.ts +++ b/src/security/permission-sets.ts @@ -305,6 +305,16 @@ export const JobSeekerSet = definePermissionSet({ fields: { 'ats_job.review_note': { readable: false, editable: false }, 'ats_employer.verification_note': { readable: false, editable: false }, + // `owner_name` mirrors the employer contact's personal name onto the + // employer row so the platform review queue can render a name instead of + // `usr_ats_*` (#67). That mirror is defensible for a platform reviewer + // because `ats_employer_member.display_name` already tells them the same + // 12 names; a job seeker holds no such fact — this set grants no access to + // `ats_employer_member` at all (measured: 403) and the pointer the mirror + // replaces renders to a seeker as an opaque id. Unsealed, a fix for two + // reviewers would hand every verified employer's contact name to all 80 + // seeker accounts. The pointer stays readable; the name does not. + 'ats_employer.owner_name': { readable: false, editable: false }, }, rowLevelSecurity: [ // Only verified employers, and only published jobs, are browsable. diff --git a/src/translations/en.ts b/src/translations/en.ts index 05976c8..9581d75 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -449,6 +449,10 @@ export const en: TranslationData = { owner: { label: 'Primary Contact', }, + owner_name: { + label: 'Primary Contact Name', + help: 'Name of the primary contact, stamped from their user record on write. A platform reviewer cannot read employer-staff user rows, so the contact pointer alone renders as an opaque id.', + }, can_publish: { label: 'Can Publish', }, diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index 0392e6a..96f5a75 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -442,6 +442,10 @@ export const zhCN: TranslationData = { owner: { label: '主要联系人', }, + owner_name: { + label: '主要联系人姓名', + help: '主要联系人的姓名,写入时从其用户记录镜像而来。平台审核人员无权读取雇主员工的用户行,因此仅凭联系人指针只会渲染出一个无意义的 id。', + }, can_publish: { label: '可发布岗位', }, diff --git a/src/views/employer.view.ts b/src/views/employer.view.ts index 64e4863..b111be9 100644 --- a/src/views/employer.view.ts +++ b/src/views/employer.view.ts @@ -28,7 +28,10 @@ const columns = [ { field: 'city' }, { field: 'verification_status' }, { field: 'service_tier' }, - { field: 'owner' }, + // `owner_name`, not `owner` — the pointer renders as a bare `usr_ats_*` for + // the platform reviewer this list is for; the stored mirror carries the name + // (#67, employer.object.ts). + { field: 'owner_name' }, ] satisfies ListColumn[]; export const EmployerViews = defineView({