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
41 changes: 41 additions & 0 deletions .changeset/system-field-name-injected-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
"@objectstack/spec": patch
---

docs(spec): SystemFieldName says which columns are actually injected (#4430)

`SystemFieldName` presents itself as the canonical protocol-level names for
system fields, but it was neither the injected set nor a complete one — and it
had the most load-bearing entry backwards. `TENANT_ID` was documented as
"Tenant isolation key" while the column the registry actually provisions is
`organization_id`, which had no constant at all. Nor did `created_by` /
`updated_by`, the other half of the audit-provenance family. Two of the seven
entries (`user_id`, `deleted_at`) are not injected either, with nothing in the
table saying so.

Consumers hand-copying a system-field list read the table as the injection set
and drifted accordingly. cloud#982 found three such copies in one package
carrying `tenant_id`, `org_id` and `space` between them — three spellings no
injection site produces — and cloud#979 was one of those copies claiming a
business field named `owner`, so every seeded row of a user's app shipped its
负责人 column blank.

**Additive only. No entry removed, no value changed**, so existing
`SystemFieldName.X` references are unaffected.

- Adds `ORGANIZATION_ID`, `CREATED_BY` and `UPDATED_BY` — the three injected
columns the table was missing.
- Records per entry whether open-core actually injects it, so the legacy
(`tenant_id`, stamped from the session's *organization* id only on an object
that declares it) and authored (`user_id`) names can no longer be mistaken
for provisioned ones.
- States 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` — already published for exactly that
purpose, and now pointed at from here.

`@objectstack/lint`'s `SYSTEM_FIELDS` is unchanged in content: it unions this
table with `FIELD_GROUP_SYSTEM_FIELDS`, which already carried all three added
names.
27 changes: 27 additions & 0 deletions packages/spec/src/system/constants/system-names.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,11 @@ describe('SystemFieldName', () => {
it('should expose all expected field names', () => {
expect(SystemFieldName.ID).toBe('id');
expect(SystemFieldName.CREATED_AT).toBe('created_at');
expect(SystemFieldName.CREATED_BY).toBe('created_by');
expect(SystemFieldName.UPDATED_AT).toBe('updated_at');
expect(SystemFieldName.UPDATED_BY).toBe('updated_by');
expect(SystemFieldName.OWNER_ID).toBe('owner_id');
expect(SystemFieldName.ORGANIZATION_ID).toBe('organization_id');
expect(SystemFieldName.TENANT_ID).toBe('tenant_id');
expect(SystemFieldName.USER_ID).toBe('user_id');
expect(SystemFieldName.DELETED_AT).toBe('deleted_at');
Expand All @@ -90,6 +93,30 @@ describe('SystemFieldName', () => {
expect(names).toContain('id');
expect(names).toContain('owner_id');
});

// The gap this table carried until #4443: `applySystemFields` injects
// `organization_id` as THE tenant key and `created_by` / `updated_by` as
// audit provenance, yet none of the three had a canonical constant — while
// `tenant_id`, which open-core never injects, was documented as "Tenant
// isolation key". Consumers hand-copying a system-field list read that as
// gospel and drifted: cloud#982 found three copies carrying `tenant_id`,
// `org_id` and `space`, none of which any injection site produces.
it('names every column the registry actually injects', () => {
const names: readonly string[] = Object.values(SystemFieldName);
// Mirrors applySystemFields' injection set (objectql registry). That
// package's own conformance test enumerates the set from the live code;
// this one only asserts the protocol table has a spelling for each member.
for (const injected of [
'organization_id',
'created_at',
'created_by',
'updated_at',
'updated_by',
'owner_id',
]) {
expect(names, injected).toContain(injected);
}
});
});

// ============================================================================
Expand Down
79 changes: 69 additions & 10 deletions packages/spec/src/system/constants/system-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,48 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId];
/**
* System Field Names — Protocol Layer Constants
*
* These constants define the canonical, protocol-level names for common system fields.
* All API calls, SDK references, and permission checks MUST use these constants
* instead of hardcoded strings or physical column names.
* The canonical, protocol-level SPELLING of each column the platform manages
* rather than the author. All API calls, SDK references, and permission checks
* MUST use these constants instead of hardcoded strings or physical column
* names.
*
* The physical storage column always equals the field key (the driver does not
* support per-field column overrides; external objects map columns via
* `external.columnMap`, ADR-0062 D7 / ADR-0015).
*
* ## ⚠️ This is a NAME registry, not the injected-column SET
*
* WHICH of these columns exists on a given object is decided PER OBJECT by
* `applySystemFields()` (`@objectstack/objectql` registry), from that object's
* own declarations: `ownership: 'org' | 'none'` withholds `owner_id`,
* `tenancy.enabled: false` withholds `organization_id`, and
* `systemFields: false` / `managedBy: 'better-auth'` disable the pass
* entirely. So the SAME NAME can be a system column on one object and an
* ordinary authored business field on another — a business field named
* `owner`, say (cloud#979, an observed failure: it was treated as a system
* column, so seeded rows left it blank).
*
* A consumer asking **"is this field system-managed ON THIS OBJECT?"** must
* therefore NOT test membership in this table. Branch on the per-field
* `Field.system` flag (`@objectstack/spec/data`) — `applySystemFields` stamps
* it on every column it injects, and no authored field carries it. That flag
* is the per-object answer; this table only answers "what is the canonical
* spelling of the column that plays role X".
*
* Related declarations, each with a different job — do not conflate them:
* - `FIELD_GROUP_SYSTEM_FIELDS` (`@objectstack/spec/data`) — names excluded
* from a default form/detail layout.
* - `PUBLIC_FORM_SERVER_MANAGED_FIELDS` (`@objectstack/spec/security`) — names
* never client-suppliable on the anonymous surface, pinned by objectql's
* `system-managed-fields-conformance.test.ts` to be exactly (what open-core
* injects ∪ documented reserved names).
*
* Every entry below records whether open-core actually INJECTS it, because
* that is the distinction hand-copied lists keep getting wrong
* (framework#4330, cloud#982 — where three copies had drifted onto
* `tenant_id`/`org_id`/`space`, two of which no injection site has ever
* produced).
*
* @example
* ```ts
* import { SystemFieldName } from '@objectstack/spec/system';
Expand All @@ -129,19 +163,44 @@ export type SystemUserId = typeof SystemUserId[keyof typeof SystemUserId];
* ```
*/
export const SystemFieldName = {
/** Primary key */
/** Primary key. Provisioned by the driver, not by `applySystemFields`. */
ID: 'id',
/** Record creation timestamp */
/** Record creation timestamp. INJECTED (audit provenance). */
CREATED_AT: 'created_at',
/** Record last-updated timestamp */
/** User who created the record (lookup to user). INJECTED (audit provenance). */
CREATED_BY: 'created_by',
/** Record last-updated timestamp. INJECTED (audit provenance). */
UPDATED_AT: 'updated_at',
/** Record owner (lookup to user) */
/** User who last modified the record (lookup to user). INJECTED (audit provenance). */
UPDATED_BY: 'updated_by',
/** Record owner (lookup to user). INJECTED unless `ownership: 'org' | 'none'`. */
OWNER_ID: 'owner_id',
/** Tenant isolation key */
/**
* THE tenant isolation key — a lookup to `sys_organization`. INJECTED unless
* tenancy is disabled for the object; org-scoping populates it on insert and
* it stays NULL on single-tenant stacks.
*/
ORGANIZATION_ID: 'organization_id',
/**
* Legacy / enterprise tenant alias. **NOT injected by open-core** — nothing
* provisions this column. It is stamped (from the session's *organization*
* id, `objectql` plugin) only on an object that DECLARES it, and it stays on
* the public-form denylist as defense-in-depth. Use
* {@link SystemFieldName.ORGANIZATION_ID} for tenant scoping; this constant
* exists so the legacy spelling still has one canonical reference.
*/
TENANT_ID: 'tenant_id',
/** Foreign key to user on session / account objects */
/**
* Foreign key to user on session / account objects. **Authored**, not
* injected — an ordinary business object may legitimately declare its own
* `user_id` lookup, so this name must never be used to classify a column as
* system-managed.
*/
USER_ID: 'user_id',
/** Soft-delete timestamp */
/**
* Soft-delete timestamp. **NOT injected by `applySystemFields`** — written by
* the lifecycle / trash layer at runtime.
*/
DELETED_AT: 'deleted_at',
} as const;

Expand Down
Loading