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
42 changes: 42 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,48 @@ column is protected — assert against `protocol.createData` when the refusal is
thing you care about. Flows are unaffected today because `assignment.flow.ts`
declares `runAs: 'system'`, which is elevated regardless.

### `isSystem` is a key to history, not a key to other people's columns

`{ context: { isSystem: true } }` exempts a write from the readonly strip. That
is what makes the section above work, and it is the whole mechanism — which
means it exempts **every** `readonly` column, not just the ones this app owns.
So it is a licence to write *history*, and it is not a licence to write a column
another component maintains.

**`readonly: true` marks two different things, and only one of them fights
back.**

| What the flag means | Example | How you seed it |
|:--------------------|:--------|:----------------|
| **A component owns this column and recomputes it.** There is a source table, and a hook derives this value from it. | `sys_user.primary_business_unit_id` — plugin-sharing recomputes it from `sys_business_unit_member.is_primary` (ADR-0057 addendum D12) | **Write the source.** Seed the rows the projection is computed from and let the platform derive the column. |
| **Nothing recomputes it; its maintenance is just somebody else's surface.** No hook, no source table — the flag keeps it off the ordinary edit form. | `sys_user.manager_id` — `readonly` because org-structure maintenance is its own admin surface (ADR-0092); `completed_at`, for that matter | **Write it directly**, from a system context, exactly as above. There is nothing else to write. |

The first kind fails in a way no gate catches, because it does not fail at
write time at all. A direct write lands, reads back correct, and survives every
boot **for as long as the source table stays empty** — the recompute has simply
never had an input. The day anything writes one source row for that record, the
hook fires and replaces your value with whatever the source says, or clears it.
Nothing errors. Measured on #74: twelve users carried a hand-written
`primary_business_unit_id` and `sys_business_unit_member` had 0 rows, for as
long as the seed had existed.

**How to tell which kind you are looking at**, before you write it:

1. Read the column's `description` in `@objectstack/platform-objects`. The first
kind says so — "a denormalised projection of …, maintained by …. Do not edit
directly; set it via …". Take that sentence literally; it is not style.
2. Grep the platform for a **writer**: `grep -rn "<column>\s*:" packages/plugins`
in the monorepo. The first kind has one (an `engine.update` in the plugin
that owns it) and a hook that calls it. The second kind has only reads.
3. If it has a writer, find what that writer reads **from**. That table is what
your seed writes.

⛔ Do not generalise from one column to its neighbours. `manager_id` and
`primary_business_unit_id` sit next to each other in the same `Organization`
field group with the same `readonly: true`, and they are opposite cases. "Both
are readonly, so treat both the same" is precisely the inference that produced
the defect.

## Product invariants — do not "improve" these away

These are the product, not preferences. If a task seems to require breaking one,
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
},
"devDependencies": {
"@objectstack/cli": "^17.2.0",
"@objectstack/platform-objects": "^17.2.0",
"@objectstack/plugin-email": "^17.2.0",
"@objectstack/plugin-sharing": "^17.2.0",
"@objectstack/service-automation": "^17.2.0",
"@objectstack/service-job": "^17.2.0",
"@objectstack/service-messaging": "^17.2.0",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { assignmentSeed } from './assignment.seed.js';
import { catalogSeed } from './catalog.seed.js';
import { dutySeed } from './duty.seed.js';
import { logEntrySeed } from './log-entry.seed.js';
import { businessUnitSeed, userSeed } from './org.seed.js';
import { businessUnitMemberSeed, businessUnitSeed, userSeed } from './org.seed.js';
import {
taskAdHocSeed,
taskAdHocTouchSeed,
Expand All @@ -29,6 +29,7 @@ import {

export {
assignmentSeed,
businessUnitMemberSeed,
businessUnitSeed,
catalogSeed,
dutySeed,
Expand Down Expand Up @@ -58,6 +59,16 @@ export {
* know that to see why the seed works. (#32: without the user rows, every
* task row is refused with `Owner is required` — measured, 0 inserted, 4
* errored.)
* - **`sys_business_unit_member` comes THIRD — after both of them.** It is
* the junction between the two, so both endpoints must exist before its
* `user_id` / `business_unit_id` natural keys can resolve; a reference that
* resolves to nothing on a `required: true` column takes the whole row with
* it, exactly as `owner` does above. Same ordering rule as the bullet
* above, one level further in — which is why it is stated here rather than
* invented as a second convention. (#74: it is also the dataset that makes
* `sys_user.primary_business_unit_id` exist at all — plugin-sharing derives
* the projection from these rows; nothing writes that column directly any
* more. See `org.seed.ts`.)
* - **The two `mode: 'update'` task passes come LAST, after both inserts.**
* Datasets targeting the same object keep their relative order through the
* sort (it is stable), and these two only work if the rows they backdate
Expand All @@ -74,8 +85,10 @@ export {
*/
export const demoSeeds: Seed[] = [
// 1. The org, first — everything below resolves its people and units here.
// The junction is third because it references the two above it.
businessUnitSeed,
userSeed,
businessUnitMemberSeed,

// 2. What roles owe, and who owes it.
catalogSeed,
Expand Down
127 changes: 108 additions & 19 deletions src/data/org.seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,20 @@ import type { Seed } from '@objectstack/spec/data';
import { ADMIN, PEOPLE, UNITS, emailOf } from './demo-org.js';

/**
* The org: `sys_business_unit` and `sys_user`.
* The org: `sys_business_unit`, `sys_user`, and the membership junction
* between them.
*
* ── Why these two are plain `Seed` literals and not `defineSeed(...)` ─────
* `defineSeed` infers its record keys from an `ObjectSchema`, and both objects
* here are the PLATFORM's, declared in `@objectstack/platform-objects`, not in
* `src/objects/`. There is no schema in this repo to hand it. Inventing a local
* stand-in to satisfy the signature would be worse than typing the literal: it
* would read as this app's description of a table it does not own, and would
* silently stop matching the day the platform adds a column. Every `duly_*`
* dataset in this directory does use `defineSeed`.
* ── Why these are plain `Seed` literals and not `defineSeed(...)` ─────────
* `defineSeed` infers its record keys from an `ObjectSchema`, and all three
* objects here are the PLATFORM's, declared in `@objectstack/platform-objects`,
* not in `src/objects/`. There is no schema in this repo to hand it. Inventing
* a local stand-in to satisfy the signature would be worse than typing the
* literal: it would read as this app's description of a table it does not own,
* and would silently stop matching the day the platform adds a column. Every
* `duly_*` dataset in this directory does use `defineSeed`.
*
* Field names are the platform's actual ones (`manager_id`,
* `primary_business_unit_id`, `manager_user_id`, `parent_business_unit_id`) —
* Field names are the platform's actual ones (`manager_id`, `manager_user_id`,
* `parent_business_unit_id`, `user_id`, `business_unit_id`, `is_primary`) —
* not guesses. `sys_user` has no `username` column, so there is none here.
*
* ── These must be seeded FIRST, and it is not a style preference ──────────
Expand All @@ -30,21 +31,26 @@ import { ADMIN, PEOPLE, UNITS, emailOf } from './demo-org.js';
* barrel lists them first so the ordering is legible without knowing that.
*
* ── One asymmetry worth knowing before you read a test ────────────────────
* References FROM a `duly_*` object INTO these two always resolve: the
* References FROM a `duly_*` object INTO these objects always resolve: the
* reference is declared on the `duly_*` schema, which this app owns, so the
* loader looks the target up in the database by name and finds it.
*
* References BETWEEN these two — `sys_user.manager_id`,
* `sys_user.primary_business_unit_id`, `sys_business_unit.manager_user_id` and
* `parent_business_unit_id` — only resolve where the platform objects are
* actually registered. Under `objectstack dev` they are (`serve` mounts
* `PlatformObjectsPlugin`), so the org chart and the manager chain link up.
* Under the bare `createStandaloneStack` kernel the vitest suites boot, they
* References BETWEEN the platform objects — `sys_user.manager_id`,
* `sys_business_unit.manager_user_id`, `parent_business_unit_id`, and the
* junction's `user_id` / `business_unit_id` — only resolve where the platform
* objects are actually registered. Under `objectstack dev` they are (`serve`
* mounts `PlatformObjectsPlugin`, and `sharing` is in
* `PLATFORM_ALWAYS_ON_CAPABILITIES` so `SharingServicePlugin` is mounted too),
* so the org chart, the manager chain and the membership rows all link up.
* Under the bare `createStandaloneStack` kernel most vitest suites boot, they
* are not registered at all: the loader finds no field definitions for
* `sys_user`, builds no reference list for it, and writes the natural key
* through verbatim. That is why `test/seed.test.ts` asserts the manager chain
* from THIS module rather than from the seeded rows — the fixture is the
* contract; what a reference column resolves to is the runtime's business.
* `test/business-unit-membership.test.ts` is the suite that boots the two
* platform plugins on purpose, because the value it reads back is one only
* they compute.
*/

/** Three levels: one company, three sites, two teams under one of them. */
Expand Down Expand Up @@ -77,6 +83,33 @@ export const businessUnitSeed: Seed = {
* one field the seed declares unchanged, and SKIPS without writing. Adding an
* `email` or a `manager_id` here would turn that skip into an UPDATE against a
* live credential-bearing account. See `demo-org.ts` for the full reasoning.
*
* ── `manager_id` is written directly; `primary_business_unit_id` is NOT ────
* Both columns are `readonly: true` on `sys_user`, and reading that as one
* fact is the mistake #74 was filed about. `readonly` marks two different
* things and only one of them fights back:
*
* - **`primary_business_unit_id` is a projection another component owns.**
* `@objectstack/plugin-sharing` recomputes it from
* `sys_business_unit_member.is_primary` — `primary-bu-projection.ts` binds
* afterInsert/afterUpdate/afterDelete hooks on the junction and runs a
* `backfillPrimaryBu` sweep at every plugin start (ADR-0057 addendum D12).
* Those hooks fire for system-context writes too, deliberately: "the
* projection must stay correct regardless of who mutates membership
* (seeds, HRIS sync, admin UI)". So this seed writes the SOURCE — see
* {@link businessUnitMemberSeed} — and lets the platform derive the
* column. It is not declared here at all.
* - **`manager_id` is a projection of nothing.** It is `readonly` because
* org-structure maintenance is its own admin surface (ADR-0092 —
* `SYS_USER_PROFILE_EDIT_FIELDS` deliberately excludes it), not because
* something recomputes it. Measured on `@objectstack/*` 17.2.0: the only
* writes to `sys_user.primary_business_unit_id` anywhere in the platform
* are the two `engine.update` calls in `primary-bu-projection.ts`, and
* there is NO writer of `manager_id` at all — every occurrence in the
* plugins is a read (`fields: ['id', 'manager_id']` in
* `business-unit-graph.ts`, `team-graph.ts`, `approval-service.ts`). So
* the direct system-context write below is the sanctioned way to seed it,
* and there is no junction to write instead.
*/
export const userSeed: Seed = {
object: 'sys_user',
Expand All @@ -88,7 +121,63 @@ export const userSeed: Seed = {
name: person.name,
email: emailOf(person.name),
manager_id: person.manager,
primary_business_unit_id: person.unit,
})),
],
};

/**
* Which unit each person belongs to — the SOURCE `sys_user.
* primary_business_unit_id` is derived from.
*
* ── Why this dataset exists at all ────────────────────────────────────────
* Before #74 the seed set `sys_user.primary_business_unit_id` directly and
* left this junction empty. That worked, and the reason it worked is the
* reason it had to change: plugin-sharing recomputes the projection from
* `sys_business_unit_member`, our table had zero rows, so no hook ever fired
* and the hand-written value simply survived. The app looked correct because
* the mechanism that would correct it had never been triggered. Two ways that
* ends, neither of them loud:
*
* - Someone writes a membership row — console, import, a later feature, a
* customer's own setup — and the hook recomputes THAT user's projection
* from the junction. The seeded value is replaced by whatever the junction
* says, or cleared when the new row is not primary. `assignment.flow.ts`
* reads `primary_business_unit_id` to stamp `duly_task.business_unit` on
* fan-out, so a cleared projection silently stamps nothing.
* - Anything resolving people THROUGH membership rather than through the
* projection sees nobody in any unit — sharing rules and hierarchy scopes
* being the obvious candidates. Our permission sets happen to read the
* projection today, which is luck, not design.
*
* ── Twelve rows, not thirteen ─────────────────────────────────────────────
* `Dev Admin` gets no membership row, for the same reason their `sys_user`
* row carries nothing but a name: a membership row would make the projection
* hook UPDATE the live credential-bearing account. The reachable state is
* unchanged from before this dataset existed — twelve users with a primary
* unit, the admin without one — which is what makes this a change of
* MECHANISM and not of data.
*
* ── The composite external id is what makes a replay idempotent ───────────
* `sys_business_unit_member` has no single-column natural key (no `name`, and
* its unique index is `(business_unit_id, user_id)`), so the dataset is keyed
* on both foreign keys — the spelling `Seed.externalId` documents for exactly
* this case: "a join / junction table keyed by both of its foreign keys …
* The reference fields are matched by their RESOLVED ids, so a composite of
* foreign keys dedupes correctly across restarts." With a single-field key
* (or none) the dataset would fall back to `mode: 'insert'` semantics and
* duplicate the whole table on every boot.
*
* `is_primary` is stated on every row even though the platform defaults it to
* `true`: it is the exact flag the projection reads, and a seed that leaned
* on the default would leave the one load-bearing column implicit.
*/
export const businessUnitMemberSeed: Seed = {
object: 'sys_business_unit_member',
externalId: ['user_id', 'business_unit_id'],
mode: 'upsert',
records: PEOPLE.map((person) => ({
user_id: person.name,
business_unit_id: person.unit,
is_primary: true,
})),
};
Loading
Loading