From c4339bf53ebe45cab2848fb59dc4dd2681b353f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:37:33 +0000 Subject: [PATCH 1/2] docs(spec): SystemFieldName says which columns are actually injected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01YRXTo7QC2A6EXXxJ5GKwsS --- .../src/system/constants/system-names.test.ts | 27 +++++++ .../spec/src/system/constants/system-names.ts | 79 ++++++++++++++++--- 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/system/constants/system-names.test.ts b/packages/spec/src/system/constants/system-names.test.ts index cb171c496b..68f36727ad 100644 --- a/packages/spec/src/system/constants/system-names.test.ts +++ b/packages/spec/src/system/constants/system-names.test.ts @@ -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'); @@ -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); + } + }); }); // ============================================================================ diff --git a/packages/spec/src/system/constants/system-names.ts b/packages/spec/src/system/constants/system-names.ts index 53731deb47..28bce71f91 100644 --- a/packages/spec/src/system/constants/system-names.ts +++ b/packages/spec/src/system/constants/system-names.ts @@ -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'; @@ -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; From f675e49611f17bfe5d0b48a5c647a8e9713c4818 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 06:39:40 +0000 Subject: [PATCH 2/2] chore: add changeset Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YRXTo7QC2A6EXXxJ5GKwsS --- .../system-field-name-injected-columns.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .changeset/system-field-name-injected-columns.md diff --git a/.changeset/system-field-name-injected-columns.md b/.changeset/system-field-name-injected-columns.md new file mode 100644 index 0000000000..919548b26e --- /dev/null +++ b/.changeset/system-field-name-injected-columns.md @@ -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.