Skip to content
Merged
Show file tree
Hide file tree
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
103 changes: 103 additions & 0 deletions docs/evidence/issue-67/README.md
Original file line number Diff line number Diff line change
@@ -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 `"<user name> · <access level>"`.

| 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: <another user> }` | 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.
2 changes: 2 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
EmployerStampHook,
EmployerMemberStampHook,
JobStampHook,
ApplicationStampHook,
Expand All @@ -10,6 +11,7 @@ import {
import { InquiryStampHook, InquiryConvertHook } from './inquiry.hook.js';

export const allHooks = [
EmployerStampHook,
EmployerMemberStampHook,
JobStampHook,
ApplicationStampHook,
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/stamp.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

/**
* `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
Expand Down
46 changes: 45 additions & 1 deletion src/objects/employer.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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({
Expand Down
10 changes: 10 additions & 0 deletions src/security/permission-sets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
Expand Down
4 changes: 4 additions & 0 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,10 @@ export const zhCN: TranslationData = {
owner: {
label: '主要联系人',
},
owner_name: {
label: '主要联系人姓名',
help: '主要联系人的姓名,写入时从其用户记录镜像而来。平台审核人员无权读取雇主员工的用户行,因此仅凭联系人指针只会渲染出一个无意义的 id。',
},
can_publish: {
label: '可发布岗位',
},
Expand Down
5 changes: 4 additions & 1 deletion src/views/employer.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading