Skip to content

Commit 04f1182

Browse files
os-zhuangclaude
andauthored
docs(spec): SystemFieldName says which columns are actually injected (#4430)
* docs(spec): SystemFieldName says which columns are actually injected The table documented `tenant_id` as "Tenant isolation key" while the column the registry actually provisions is `organization_id` — which had no constant at all, alongside the equally-missing `created_by` / `updated_by`. Two of the seven entries (`user_id`, `deleted_at`) are not injected either, with nothing saying so. Consumers hand-copying a system-field list read this as the injection set and drifted accordingly: cloud#982 found three copies carrying `tenant_id`, `org_id` and `space` between them, none of which any injection site produces, and cloud#979 was one of those copies claiming a business field named `owner` so every seeded row shipped it blank. Additive only — no entry removed, no value changed: - add ORGANIZATION_ID, CREATED_BY, UPDATED_BY, the three injected columns the table was missing; - record per entry whether open-core injects it, so the legacy (`tenant_id`) and authored (`user_id`) names cannot be mistaken for provisioned ones; - state in the module doc that this is a NAME registry, not the injected set — `applySystemFields` decides that per object from `ownership` / `tenancy` / `systemFields`, so the same name is a system column on one object and business data on the next. A consumer asking "is this field system-managed on THIS object" branches on `Field.system`, which is already published for exactly that and which the doc now points at. `@objectstack/lint`'s SYSTEM_FIELDS is unaffected in content: it unions this table with FIELD_GROUP_SYSTEM_FIELDS, which already carried all three added names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXTo7QC2A6EXXxJ5GKwsS * chore: add changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YRXTo7QC2A6EXXxJ5GKwsS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1ee48bc commit 04f1182

3 files changed

Lines changed: 137 additions & 10 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): SystemFieldName says which columns are actually injected (#4430)
6+
7+
`SystemFieldName` presents itself as the canonical protocol-level names for
8+
system fields, but it was neither the injected set nor a complete one — and it
9+
had the most load-bearing entry backwards. `TENANT_ID` was documented as
10+
"Tenant isolation key" while the column the registry actually provisions is
11+
`organization_id`, which had no constant at all. Nor did `created_by` /
12+
`updated_by`, the other half of the audit-provenance family. Two of the seven
13+
entries (`user_id`, `deleted_at`) are not injected either, with nothing in the
14+
table saying so.
15+
16+
Consumers hand-copying a system-field list read the table as the injection set
17+
and drifted accordingly. cloud#982 found three such copies in one package
18+
carrying `tenant_id`, `org_id` and `space` between them — three spellings no
19+
injection site produces — and cloud#979 was one of those copies claiming a
20+
business field named `owner`, so every seeded row of a user's app shipped its
21+
负责人 column blank.
22+
23+
**Additive only. No entry removed, no value changed**, so existing
24+
`SystemFieldName.X` references are unaffected.
25+
26+
- Adds `ORGANIZATION_ID`, `CREATED_BY` and `UPDATED_BY` — the three injected
27+
columns the table was missing.
28+
- Records per entry whether open-core actually injects it, so the legacy
29+
(`tenant_id`, stamped from the session's *organization* id only on an object
30+
that declares it) and authored (`user_id`) names can no longer be mistaken
31+
for provisioned ones.
32+
- States in the module doc that this is a NAME registry, not the injected set.
33+
`applySystemFields` decides that per object from `ownership` / `tenancy` /
34+
`systemFields`, so the same name is a system column on one object and
35+
business data on the next. A consumer asking "is this field system-managed on
36+
THIS object" branches on `Field.system` — already published for exactly that
37+
purpose, and now pointed at from here.
38+
39+
`@objectstack/lint`'s `SYSTEM_FIELDS` is unchanged in content: it unions this
40+
table with `FIELD_GROUP_SYSTEM_FIELDS`, which already carried all three added
41+
names.

packages/spec/src/system/constants/system-names.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,11 @@ describe('SystemFieldName', () => {
7878
it('should expose all expected field names', () => {
7979
expect(SystemFieldName.ID).toBe('id');
8080
expect(SystemFieldName.CREATED_AT).toBe('created_at');
81+
expect(SystemFieldName.CREATED_BY).toBe('created_by');
8182
expect(SystemFieldName.UPDATED_AT).toBe('updated_at');
83+
expect(SystemFieldName.UPDATED_BY).toBe('updated_by');
8284
expect(SystemFieldName.OWNER_ID).toBe('owner_id');
85+
expect(SystemFieldName.ORGANIZATION_ID).toBe('organization_id');
8386
expect(SystemFieldName.TENANT_ID).toBe('tenant_id');
8487
expect(SystemFieldName.USER_ID).toBe('user_id');
8588
expect(SystemFieldName.DELETED_AT).toBe('deleted_at');
@@ -90,6 +93,30 @@ describe('SystemFieldName', () => {
9093
expect(names).toContain('id');
9194
expect(names).toContain('owner_id');
9295
});
96+
97+
// The gap this table carried until #4443: `applySystemFields` injects
98+
// `organization_id` as THE tenant key and `created_by` / `updated_by` as
99+
// audit provenance, yet none of the three had a canonical constant — while
100+
// `tenant_id`, which open-core never injects, was documented as "Tenant
101+
// isolation key". Consumers hand-copying a system-field list read that as
102+
// gospel and drifted: cloud#982 found three copies carrying `tenant_id`,
103+
// `org_id` and `space`, none of which any injection site produces.
104+
it('names every column the registry actually injects', () => {
105+
const names: readonly string[] = Object.values(SystemFieldName);
106+
// Mirrors applySystemFields' injection set (objectql registry). That
107+
// package's own conformance test enumerates the set from the live code;
108+
// this one only asserts the protocol table has a spelling for each member.
109+
for (const injected of [
110+
'organization_id',
111+
'created_at',
112+
'created_by',
113+
'updated_at',
114+
'updated_by',
115+
'owner_id',
116+
]) {
117+
expect(names, injected).toContain(injected);
118+
}
119+
});
93120
});
94121

95122
// ============================================================================

packages/spec/src/system/constants/system-names.ts

Lines changed: 69 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,14 +110,48 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId];
110110
/**
111111
* System Field Names — Protocol Layer Constants
112112
*
113-
* These constants define the canonical, protocol-level names for common system fields.
114-
* All API calls, SDK references, and permission checks MUST use these constants
115-
* instead of hardcoded strings or physical column names.
113+
* The canonical, protocol-level SPELLING of each column the platform manages
114+
* rather than the author. All API calls, SDK references, and permission checks
115+
* MUST use these constants instead of hardcoded strings or physical column
116+
* names.
116117
*
117118
* The physical storage column always equals the field key (the driver does not
118119
* support per-field column overrides; external objects map columns via
119120
* `external.columnMap`, ADR-0062 D7 / ADR-0015).
120121
*
122+
* ## ⚠️ This is a NAME registry, not the injected-column SET
123+
*
124+
* WHICH of these columns exists on a given object is decided PER OBJECT by
125+
* `applySystemFields()` (`@objectstack/objectql` registry), from that object's
126+
* own declarations: `ownership: 'org' | 'none'` withholds `owner_id`,
127+
* `tenancy.enabled: false` withholds `organization_id`, and
128+
* `systemFields: false` / `managedBy: 'better-auth'` disable the pass
129+
* entirely. So the SAME NAME can be a system column on one object and an
130+
* ordinary authored business field on another — a business field named
131+
* `owner`, say (cloud#979, an observed failure: it was treated as a system
132+
* column, so seeded rows left it blank).
133+
*
134+
* A consumer asking **"is this field system-managed ON THIS OBJECT?"** must
135+
* therefore NOT test membership in this table. Branch on the per-field
136+
* `Field.system` flag (`@objectstack/spec/data`) — `applySystemFields` stamps
137+
* it on every column it injects, and no authored field carries it. That flag
138+
* is the per-object answer; this table only answers "what is the canonical
139+
* spelling of the column that plays role X".
140+
*
141+
* Related declarations, each with a different job — do not conflate them:
142+
* - `FIELD_GROUP_SYSTEM_FIELDS` (`@objectstack/spec/data`) — names excluded
143+
* from a default form/detail layout.
144+
* - `PUBLIC_FORM_SERVER_MANAGED_FIELDS` (`@objectstack/spec/security`) — names
145+
* never client-suppliable on the anonymous surface, pinned by objectql's
146+
* `system-managed-fields-conformance.test.ts` to be exactly (what open-core
147+
* injects ∪ documented reserved names).
148+
*
149+
* Every entry below records whether open-core actually INJECTS it, because
150+
* that is the distinction hand-copied lists keep getting wrong
151+
* (framework#4330, cloud#982 — where three copies had drifted onto
152+
* `tenant_id`/`org_id`/`space`, two of which no injection site has ever
153+
* produced).
154+
*
121155
* @example
122156
* ```ts
123157
* import { SystemFieldName } from '@objectstack/spec/system';
@@ -129,19 +163,44 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId];
129163
* ```
130164
*/
131165
export const SystemFieldName = {
132-
/** Primary key */
166+
/** Primary key. Provisioned by the driver, not by `applySystemFields`. */
133167
ID: 'id',
134-
/** Record creation timestamp */
168+
/** Record creation timestamp. INJECTED (audit provenance). */
135169
CREATED_AT: 'created_at',
136-
/** Record last-updated timestamp */
170+
/** User who created the record (lookup to user). INJECTED (audit provenance). */
171+
CREATED_BY: 'created_by',
172+
/** Record last-updated timestamp. INJECTED (audit provenance). */
137173
UPDATED_AT: 'updated_at',
138-
/** Record owner (lookup to user) */
174+
/** User who last modified the record (lookup to user). INJECTED (audit provenance). */
175+
UPDATED_BY: 'updated_by',
176+
/** Record owner (lookup to user). INJECTED unless `ownership: 'org' | 'none'`. */
139177
OWNER_ID: 'owner_id',
140-
/** Tenant isolation key */
178+
/**
179+
* THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless
180+
* tenancy is disabled for the object; org-scoping populates it on insert and
181+
* it stays NULL on single-tenant stacks.
182+
*/
183+
ORGANIZATION_ID: 'organization_id',
184+
/**
185+
* Legacy / enterprise tenant alias. **NOT injected by open-core** — nothing
186+
* provisions this column. It is stamped (from the session's *organization*
187+
* id, `objectql` plugin) only on an object that DECLARES it, and it stays on
188+
* the public-form denylist as defense-in-depth. Use
189+
* {@link SystemFieldName.ORGANIZATION_ID} for tenant scoping; this constant
190+
* exists so the legacy spelling still has one canonical reference.
191+
*/
141192
TENANT_ID: 'tenant_id',
142-
/** Foreign key to user on session / account objects */
193+
/**
194+
* Foreign key to user on session / account objects. **Authored**, not
195+
* injected — an ordinary business object may legitimately declare its own
196+
* `user_id` lookup, so this name must never be used to classify a column as
197+
* system-managed.
198+
*/
143199
USER_ID: 'user_id',
144-
/** Soft-delete timestamp */
200+
/**
201+
* Soft-delete timestamp. **NOT injected by `applySystemFields`** — written by
202+
* the lifecycle / trash layer at runtime.
203+
*/
145204
DELETED_AT: 'deleted_at',
146205
} as const;
147206

0 commit comments

Comments
 (0)