|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | 3 | import { ServiceObject, ObjectSchema, ObjectOwnership, provisionPrimary, resolveCrudAffordances, isTenancyDisabled, LEGACY_API_METHODS, AUDIT_PROVENANCE_FIELDS, type AuditProvenanceField } from '@objectstack/spec/data'; |
| 4 | +import { SystemFieldName } from '@objectstack/spec/system'; |
4 | 5 | import { resolveTenancyPosture, resolveSearchPinyinEnabled } from '@objectstack/types'; |
5 | 6 | import { postureEnforcesWall } from '@objectstack/spec/security'; |
6 | 7 | import { provisionSearchCompanion } from './search-companion.js'; |
@@ -233,6 +234,17 @@ export interface SchemaRegistryOptions { |
233 | 234 | * opt-out is harmless (a spare nullable column), whereas forgetting to |
234 | 235 | * ADD ownership — the failure mode we are eliminating — silently breaks |
235 | 236 | * every owner-keyed feature. |
| 237 | + * - `owning_business_unit_id` — [ADR-0117 D1] record-level ORG-UNIT |
| 238 | + * ownership (lookup to `sys_business_unit`): the tier between `owner_id` |
| 239 | + * (a person) and `organization_id` (a tenant). Injected on the same |
| 240 | + * objects `owner_id` is, PLUS the `ownership: 'business_unit'` tier which |
| 241 | + * has an owning unit but deliberately no owning person (inventory, asset |
| 242 | + * ledgers, departmental budgets). Withheld for `org` / `none` exactly like |
| 243 | + * `owner_id`. Shaped like `organization_id`, not like `owner_id` |
| 244 | + * (`readonly: true`, `hidden: true`): it is a SERVER-STAMPED scope anchor, |
| 245 | + * and the stamping middleware (ADR-0117 D2/D4) has not landed — so the |
| 246 | + * column is provisioned but inert. See {@link applySystemFields}' injection |
| 247 | + * site for why that shape presumes nothing about the undecided D2 policy. |
236 | 248 | */ |
237 | 249 | /** |
238 | 250 | * Column definitions for the audit-provenance family, keyed by the spec's |
@@ -299,6 +311,15 @@ const AUDIT_FIELD_GOVERNANCE: Record<AuditProvenanceField, Record<string, unknow |
299 | 311 | AUDIT_PROVENANCE_FIELDS.map((name) => [name, { readonly: true, system: true }]), |
300 | 312 | ) as unknown as Record<AuditProvenanceField, Record<string, unknown>>; |
301 | 313 |
|
| 314 | +/** |
| 315 | + * [ADR-0117 D1] The injected BU-ownership column name, spelled out so it greps |
| 316 | + * from this file, and annotated with the spec's registered protocol name so the |
| 317 | + * two cannot drift: a rename in `SystemFieldName.OWNING_BUSINESS_UNIT_ID` makes |
| 318 | + * this line a compile error rather than a silently orphaned injection. |
| 319 | + */ |
| 320 | +const OWNING_BUSINESS_UNIT_FIELD: typeof SystemFieldName.OWNING_BUSINESS_UNIT_ID = |
| 321 | + 'owning_business_unit_id'; |
| 322 | + |
302 | 323 | export function applySystemFields( |
303 | 324 | schema: ServiceObject, |
304 | 325 | opts: { multiTenant: boolean } |
@@ -355,12 +376,42 @@ export function applySystemFields( |
355 | 376 | // authors silently ship objects with no working ownership at all. |
356 | 377 | // `ownership` is now a declared ObjectSchema field (record-ownership model), |
357 | 378 | // so it reads off the typed schema — no `as any` (#3175). |
358 | | - const ownership = schema.ownership; |
359 | | - const wantOwner = |
360 | | - ownership !== 'org' && |
361 | | - ownership !== 'none' && |
362 | | - !(schema as any).managedBy && |
363 | | - !schema.name.startsWith('sys_'); |
| 379 | + // |
| 380 | + // [ADR-0117 D1 / #5677] Widened to `string` on purpose. The spec enum is |
| 381 | + // `'user' | 'org' | 'none'` TODAY; D1's fourth tier `'business_unit'` is |
| 382 | + // declared in #5678, strictly AFTER this PR — that ordering is the whole |
| 383 | + // point of #5677: the engine must recognise the tier BEFORE the schema can |
| 384 | + // emit it, or the tier's first appearance would be judged by the branch |
| 385 | + // below and get the INVERSE of what D1 declares. Without the widening `tsc` |
| 386 | + // rejects the comparison as a no-overlap literal test; with it the engine is |
| 387 | + // ready and the enum lands into a runtime that already honours it. |
| 388 | + const ownership: string | undefined = schema.ownership; |
| 389 | + |
| 390 | + // Platform-managed tables and the `sys_*` namespace never carry a per-record |
| 391 | + // ownership anchor, whichever tier is declared — unchanged, and shared by |
| 392 | + // both anchors below so they cannot drift apart. |
| 393 | + const ownershipEligible = !(schema as any).managedBy && !schema.name.startsWith('sys_'); |
| 394 | + |
| 395 | + // [ADR-0117 D1 / #5677] POSITIVE LIST, deliberately — this used to read |
| 396 | + // `ownership !== 'org' && ownership !== 'none'`, i.e. a DENY-list, so ANY |
| 397 | + // value outside the two exclusions fell through to "inject `owner_id`". |
| 398 | + // That default is safe only while the enum has exactly three members: D1's |
| 399 | + // `business_unit` tier means "owned by a UNIT, not a person" (`owner_id` ❌, |
| 400 | + // `owning_business_unit_id` ✅), and under the deny-list it would have been |
| 401 | + // stamped with `owner_id` — the exact inverse. Behaviour for the three |
| 402 | + // values that exist today is IDENTICAL (`undefined`/`user` inject, `org`/ |
| 403 | + // `none` do not); the change is only that a NEW tier no longer inherits the |
| 404 | + // owner branch by accident. |
| 405 | + const wantOwner = ownershipEligible && (ownership === undefined || ownership === 'user'); |
| 406 | + |
| 407 | + // [ADR-0117 D1] The BU anchor covers the owner tiers PLUS `business_unit`: |
| 408 | + // |
| 409 | + // ownership owner_id owning_business_unit_id |
| 410 | + // undefined/user ✅ ✅ |
| 411 | + // business_unit ❌ ✅ |
| 412 | + // org ❌ ❌ |
| 413 | + // none ❌ ❌ |
| 414 | + const wantOwningBusinessUnit = wantOwner || (ownershipEligible && ownership === 'business_unit'); |
364 | 415 |
|
365 | 416 | const additions: Record<string, any> = {}; |
366 | 417 | // Platform-owned field settings that must WIN over a declared field, rather |
@@ -436,6 +487,52 @@ export function applySystemFields( |
436 | 487 | }; |
437 | 488 | } |
438 | 489 |
|
| 490 | + // [ADR-0117 D1] Record-level business-unit ownership. Shaped after |
| 491 | + // `organization_id` (server-stamped scope anchor), NOT after `owner_id` |
| 492 | + // (a user-assignable business field) — the two differ in exactly the keys |
| 493 | + // that decide who may write and whether it renders: |
| 494 | + // |
| 495 | + // • `readonly: true` — D3 requires the value to be derived and validated |
| 496 | + // server-side (`record.organization_id` must equal the unit's org), so a |
| 497 | + // client-supplied value is overwritten, never trusted. Reassignment is a |
| 498 | + // TRANSFER-class operation gated on `allowTransfer` (D4), which has not |
| 499 | + // landed; until it does, no client write path may set this column. |
| 500 | + // • `hidden: true` — nothing stamps the column yet (D2's policy is |
| 501 | + // undecided, D8's backfill unwritten), so it is provisioned-but-inert. |
| 502 | + // Surfacing a permanently-NULL lookup on every business object's layout |
| 503 | + // would advertise a capability the runtime does not deliver (Prime |
| 504 | + // Directive #10). Presentation is a one-key flip for the PR that lands |
| 505 | + // stamping — a capability grant is not. |
| 506 | + // • `required: false` — nullable, like every other injected column. |
| 507 | + // |
| 508 | + // ⚠️ None of the three presumes ADR-0117 D2 (`pinned`/`follow_owner`/ |
| 509 | + // `transferable`), which is NOT YET RULED. They are the fail-closed shape: |
| 510 | + // they grant no capability, so every D2 outcome remains reachable. D2's |
| 511 | + // `owningBusinessUnit.required` is an INSERT-time rejection rule, not column |
| 512 | + // nullability — it cannot be read off this definition either. `system: true` |
| 513 | + // writes (the future stamping middleware, seeds) are checked downstream of |
| 514 | + // `readonly`, so the flag blocks clients without blocking the platform — |
| 515 | + // exactly how `organization_id` is stamped today. |
| 516 | + // |
| 517 | + // No index: the hierarchical predicate that would use one (`… IN (units)`, |
| 518 | + // D6) ships with the enterprise scope resolver. An index on a column nothing |
| 519 | + // writes and nothing filters is dead weight — the same reasoning that gates |
| 520 | + // `organization_id`'s index on `multiTenant`. |
| 521 | + if (wantOwningBusinessUnit && !schema.fields?.[OWNING_BUSINESS_UNIT_FIELD]) { |
| 522 | + additions[OWNING_BUSINESS_UNIT_FIELD] = { |
| 523 | + type: 'lookup', |
| 524 | + reference: 'sys_business_unit', |
| 525 | + label: 'Owning Business Unit', |
| 526 | + required: false, |
| 527 | + hidden: true, |
| 528 | + readonly: true, |
| 529 | + system: true, |
| 530 | + description: |
| 531 | + 'Record-level business-unit ownership (ADR-0117 D1). Server-stamped scope anchor; ' + |
| 532 | + 'NULL until the stamping middleware lands.', |
| 533 | + }; |
| 534 | + } |
| 535 | + |
439 | 536 | if (Object.keys(additions).length === 0 && Object.keys(overrides).length === 0) return schema; |
440 | 537 |
|
441 | 538 | return { |
|
0 commit comments