Skip to content

Commit 301eec6

Browse files
feat(spec)!: retire the object's externalSharingModel — the external org-wide default no principal ever read (#23268) (#23441)
Fixes #23268 Clause-②: no Executes ruling of record `6159300530` on #23268, letter A (the maintainer's 「23268 同意」): `externalSharingModel` is retired from `packages/spec` on the v18 line, ADR-0049 enforce-or-remove. The ADR-0090 D11 sentence is a separate Tier H PR (see "Companion" below); this PR touches no governed surface. ## What the key was, and why it goes ADR-0090 D11 declared `externalSharingModel` as a second org-wide default for EXTERNAL (portal / partner) principals. Re-measured on `origin/main` `e4ec6ec1a` before this branch: - `git grep -n "externalSharingModel" origin/main -- 'packages/**.ts' ':!*.test.ts' ':!packages/spec/**'`: no row-pipeline reader. The only readers were authoring checks of the key against itself (the publish linter's alias and wider-than-internal branches, and the `plugin-security` posture gate's env-tighten comparison), plus the `metadata-core` keep table and comments. Positive control on the same tree: `git grep -c "sharingModel" origin/main -- packages/plugins/plugin-security/src` is non-zero. - No runtime principal is external: `packages/core/src/security/assemble-execution-context.ts` assembles `audience: undefined`. - The authorization kernel's `EXTERNAL` rung (ADR-0095 / ADR-0099) is explicitly shared rows only, never OWD-derived. - Authored instances in this repository: two showcase objects (`showcase_account`, `showcase_announcement`). ## The retirement kit (the `spec-property-retirement` playbook, surface by surface) - **Tombstone.** `retiredKey()` on `ObjectSchema` (a `strictObject`), `packages/spec/src/data/object.zod.ts`. The prescription opens with `` `object.externalSharingModel` ``, says it never had an effect, prescribes "Delete the key", names what does work (`sharingModel`, explicit shares, share links via `publicSharing`) and closes with the house `os migrate meta --from 17` sentence. The route is the tombstone, not the strict deletion: `tsc` fails at every typed authoring site, and the refusal carries the prescription. - **Neighbouring text.** The `tenancy.crossTenantAccess` retirement hint no longer points at the key (ruling item 1). The `publicSharing` guidance entry that refuses a mis-nested `publicSharing.externalSharingModel` now says the key is removed, instead of steering the author "one level up" to a dead key. `publicSharing` itself is untouched (ruling item 3). The `audience` doc comment in `kernel/execution-context.zod.ts` changes one sentence. - **D2 conversion `object-external-sharing-model-removed`.** Protocol 18, `retiredFromLoadPath`, `retiredAfter: '17.7.0'`, order 64. Inserted in `MAJOR_18_CONVERSIONS` by identifier (between `metricFiltersRemoved` and `objectGridDefaultFiltersRemoved`) and defined directly above the latter's definition. It strips the key from every `objects[]` entry, one notice per object. Fixture: one object carrying it, one canonical control. - **Registration.** `RETIRED_KEYS_BY_MAJOR[18]` gains `data/Object:externalSharingModel` (`entries/retired-keys/18.data__Object__externalSharingModel.ts`). D3 entry `object-external-sharing-model-retired` (`entries/semantic/`), with its `STEP18_RATIONALE` fragment (order 96). - **Ledger.** `liveness/object.json`: the row goes `planned` (it still pointed at #2696, which is done) to `dead`, kept as a tombstone row with `verifiedAt` 2026-10-14; the README `object` row records it; `state-counts/object.md` regenerated (53 live, 1 dead, 54 classified). - **Generated.** `authorable-surface/data.json` (`data/Object:externalSharingModel [RETIRED]`) and three reference pages (`references/data/object.mdx`, `references/api/metadata.mdx`, `references/system/migration.mdx`), regenerated by `check:generated --fix`. The four ratchets other than `authorable-surface` do not move, as expected for a key-level tombstone on a def that still emits. - **Lint (`@objectstack/lint`).** `security-external-wider-than-internal` and its exported `SECURITY_EXTERNAL_WIDER` are removed; `security-owd-alias` no longer reads the external key; `OWD_WIDTH` (its only user) goes; comments in `authoring-rules.ts`, `rule-explanations.ts` and `scripts/check-doc-security-posture.mjs` follow. - **Posture gate (`@objectstack/plugin-security`).** R1 (env-tighten-only) compares `sharingModel` alone; its external half goes with the key. - **Examples.** `showcase_account` and `showcase_announcement` drop the key; `security/index.ts`, `security/positions.ts`, `coverage.ts` and `docs/showcase_tour_security.md` stop describing an external dial. - **Docs.** `permissions/sharing-rules.mdx` (the external-dial section becomes "External principals — no external org-wide default"), `permissions/authorization.mdx` (three passages), `protocol/objectql/security.mdx`, `docs/design/permission-model.md` (two passages), plus the regenerated reference pages. `content/docs/releases/**` is not touched. - **QA item.** `docs/qa/platform-checklist/areas/access-security.json` `access-security.owd-save-gate` revision 5: the external clauses go (the Studio external-wider clause; the external-wider refusal, admit and shipped-declarations clauses are rewritten to the tombstone's 422 on every door, draft included, the admit without the key, and "the showcase ships no retired key"). The packaged-object seal clauses are unchanged. `surface` moves `mixed` to `api` because no browser step remains. - **Comments.** `metadata-protocol/src/protocol.ts` (one comment line in the authoring-gate docblock), `plugin-audit/src/objects/sys-comment.object.ts` and its pin's prescription text. - **Changeset.** `.changeset/23268-external-sharing-model-retired.md`: `@objectstack/spec` `minor` with the BREAKING banner, the FROM to TO table and the one-line fix; `@objectstack/lint` `minor` (the removed export); `@objectstack/plugin-security` `patch`; one ADR-0087 disposition, `registered object-external-sharing-model-removed, object-external-sharing-model-retired`. ## Census class: `registered`, with a mechanical strip Stored metadata can carry the key: Studio's object settings offered the select, and `PUT /api/v1/meta/object` persisted it until this release (`saveMetaItem` validated it against the closed enum). So the class is `registered`, not `not-required`: `applyConversionsToStoredItem` replays the D2 strip over a stored `object` row as `{ objects: [row] }`, pinned in the new test. **For the cloud seat (ruling item 5):** cloud's v18 stored preview, `os migrate meta --stored`, lists the tenant rows carrying `externalSharingModel` before the upgrade; the replayed strip heals them on read. ## Pins that moved, and why each still bears load - **New pin** `packages/spec/src/data/object-external-sharing-model-retirement.test.ts` (repo project; listed in `vitest.repo-tests.json`), 18 tests: the tombstone at the schema, the `object` registry binding, `ObjectSchema.create()` and `defineStack` (`STACK_SCHEMA_INVALID` / 422), every old value refused, the control, the walked shape, the mis-nested `publicSharing` guidance, the `tsc` channel (`@ts-expect-error`, compiled by `check:test-typecheck`); the D2 strip at the stored-row seam and the boot door (with a lit control), per-object notices, idempotence, retirement from the load path; the three registrations; and a tree-scoped absence walk over the radius `@objectstack/spec#test` already declares (the same five roots as the precedents; `check:cross-package-test-inputs` green). Exclusions are listed in the file with a reason each: the declaration file, the `metadata-core` keep table (ADR-0106 D1 classifies every key of the walked shape), the REST refusal pin, and four ledger-advisory witnesses (below). - **REST door** `packages/rest/src/meta-object-owd-gate.test.ts`: the external-wider 422 cases flip to the substance of the new semantics: a body carrying the key, with a pair the old rule ADMITTED, is refused `422 INVALID_METADATA` with the prescription on the active, `?package=` and `?mode=draft` doors, and nothing is stored. The draft-promotion pin is kept on `security-owd-unset`; the package-author carve-out pin is kept on an OWD-less body; legal-pair rows become the three OWD values. - **Posture gate** `object-posture-gate.test.ts`: the R2-retirement and external R1 cases go; R1's internal cases and the wiring pin stay. - **Lint.** `validate-security-posture.test.ts`: the rule's two tests and reachability fixtures go; the `obj` receiver's read-key list drops the key, which is the structural pin that the linter reads nothing external; push-site count 18 to 16. `lint-liveness-properties.test.ts`: the key is now a `dead` row, and a `dead` verdict warns on an unparsed intake without an opt-in, so the "the ledger loaded" witnesses keep it; the real-ledger case flips from the planned branch to the dead branch (rule id, default hint, `row.status === 'dead'`); the planned-with-opt-in control moves to `page.print`; the opt-in anti-vacuity is narrowed because no shipped opt-in lacks an authorHint any more (the class stays held by the synthetic case). - **CLI.** `validate-lint-mapping-connector-source.test.ts` (integration) and `validate-json-warning-parity.e2e.test.ts` (nightly tier) authored the key through `defineStack`, which now refuses it; their warned-row control moves to `tool.outputSchema` (`experimental`). `i18n-flow-liveness-gate.test.ts`: comment only. - **Spec.** `object.test.ts` (the `crossTenantAccess` hint asserts "explicit shares" and not the key), `solution-blueprint.test.ts` (the undeclared sibling becomes `publicSharing`), `metadata-form-zod-reconciliation.test.ts` (the `planned` omit row goes: tombstones are skipped by the detector), `retired-key-removal-version.pin.test.ts` (JOIN gains the conversion). - **Dogfood** `guest-grants-channel.dogfood.test.ts`: the one fixture object that declared the key is removed with its grant, endpoint and seed (28 of 28 pass with `expression-conformance`). Error message pin sweep: `git grep -n "security-external-wider-than-internal\|SECURITY_EXTERNAL_WIDER\|owd_external_wider"` outside history files now hits only prose that records the retirement, the published skill reference below, ADRs, and pending changesets. ## Evidence (head `1060ed3e7`, after merging `origin/main` `04a314873` through `scripts/pm/os-regen-merge.sh`) All exit 0 unless noted. - `pnpm --filter @objectstack/spec build`, then `check:generated`: all 14 artifacts up to date. - `@objectstack/spec` local project in three shards before the second merge: 225 / 7,366, 224 / 6,477 (1 todo), 224 / 6,619 plus one red fixed in `retired-key-removal-version.pin.test.ts` (JOIN), whose re-run is 1 / 20 green. After the merge: conversions, migrations, `object.test.ts` and that pin, 20 / 956. - `@objectstack/spec` repo project in two shards: 30 / 470 and 29 / 471. NOT MEASURED: `scripts/build-schemas-check-mode.test.ts`, excluded because it alone exceeds the foreground cap (the precedent's reading); `check:authorable-surface` is green. After the merge: the new pin, `retired-key-migrate-sentence.test.ts` and `conversions-major18-merge.test.ts`, 3 / 45. - `@objectstack/lint` in three shards: 46 / 2,606, 46 / 1,862, and 46 files with 3 suites red on `Cannot find package '@objectstack/spec/ui'` while a concurrent gate rebuilt `packages/spec/dist`; those 3 re-run 3 / 33. After the merge: the two posture and liveness files, 2 / 251. - `@objectstack/plugin-security`: 185 files, 3,772 passed, 45 skipped. `@objectstack/rest` `meta-object-owd-gate.test.ts` 11 / 11 (before and after the merge). `@objectstack/metadata-core` `object-schema-fls-references.test.ts` 52 / 52. `@objectstack/plugin-audit` `sys-comment-retired-fields.test.ts` 8 / 8. `@objectstack/example-showcase` 32 / 411. `@objectstack/dogfood` (guest grants + expression conformance) 2 / 28. - `@objectstack/cli`: unit `i18n-flow-liveness-gate.test.ts` 8 / 8; integration `validate-lint-mapping-connector-source.test.ts` 3 / 3; nightly tier (`OS_TEST_TIERS=nightly`) `validate-json-warning-parity.e2e.test.ts` 3 / 3. - `typecheck`: spec (including `check:test-typecheck`, which compiles the pin's `@ts-expect-error`), lint, plugin-security, plugin-audit, rest, cli, dogfood, example-showcase. - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths) derives 125 commands at `1060ed3e7`; the run and its `--ran` reconciliation are in the report on the card. On the first full run (head `a4633774e`), 119 of 125 exited 0. `check:platform-checklist` exits 1 with 8 findings that are identical, line for line, on `e4ec6ec1a` (7 absent `#symbol` anchors in `metadata-protocol` / `service-storage` citations plus the `attachments-storage.json` anchor floor); none is in a file this diff touches, and the new anchor this diff cites (`object.zod.ts#OBJECT_EXTERNAL_SHARING_MODEL_REMOVED`) resolves. Four exited 3 (PREREQUISITE NOT MET) and three were re-run green after building their closures: `check:i18n` (10 packages in sync), `check:i18n-coverage` (14 configs, none new), `check:skill-examples` (263 examples). On the final head `1060ed3e7` the full set ran: 125 derived, 125 run, 124 exit 0 (`check:dual-build-cjs-loads` included); `check:platform-checklist` exit 1 with the 8 findings above; `--ran`: 125 accounted, 0 NOT-MEASURED. *(Corrected by the seat from the dev's report `6164903067`.)* `check-plugin-teardown-shape --self-test` exited 3 on a shallow clone missing its pinned fixture commit; after fetching that commit it passes (48 cases). - ESLint, as a proven narrowing rather than the repo-wide `pnpm lint`. Population: `eslint.config.mjs` lints `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`. `--no-inline-config --format json` over the 36 changed lintable files at `1060ed3e7`: 36 files, 0 errors, 0 warnings. Invariance: the config enables no type-aware linting (no `parserOptions.project`, no `projectService`) and the diff touches neither the config nor a baseline, so no verdict on an untouched file can move. - Changed lines: 1,334 (+884 / -450, 52 files), under the 3,000 human-merge line. No governed surface is touched. ## Ablations (one-shot, through `scripts/ablation-replace.mjs`, each restored by blob equal to HEAD with an empty `git diff HEAD`; spec-source imports, no `dist/` involved) - The tombstone replaced by the old `z.enum([...]).optional()` (anchor 1 to 0): 7 of the pin's 18 tests went red (schema refusal, every value, registry binding, `create()`, `defineStack`, the tsc channel's parse leg, the boot door), 11 stayed green. - `externalSharingModel: 'private'` planted on `showcase_announcement` (anchor 1 to 0): exactly the tree-scoped walk went red, naming `examples/app-showcase/src/data/objects/announcement.object.ts`. A first attempt whose replacement still contained the anchor was refused by the tool as a no-op and re-run with a landing replacement. ## Studio The objectui follow-up (the object settings select and the Ext badge) is the PM's card (ruling item 4), not in this diff. Until it lands, a Studio author who picks a value in that select gets the 422 with the prescription on save, the same refusal pinned above; a stored row carrying the key is healed on read by the replayed strip. The console reads the key from an untyped draft, so nothing in objectui stops compiling: measured at the `.objectui-sha` pin `1fb1ced3`, every read is `typeof draft.externalSharingModel === 'string'` or on an untyped record or an `as any` fixture. ## Companion The ADR-0090 D11 sentence (ruling item 2) is a separate Tier H draft PR touching only `docs/adr/0090-permission-model-v2-concept-convergence.md`; that PR refers to the card and leaves its state to this one. ## Acceptance notes - **Bound kept:** no `docs/adr/**`, `skills/**`, `.claude/**`, `.objectui-sha` or `content/docs/releases/**`. - **Not edited, now stale (governed):** `skills/objectstack-data/references/lint-rules.md:23` lists `security-external-wider-than-internal` as a live rule. `skills/**` is Tier H; a one-row deletion for whoever touches that file next. - **Not edited, history:** ADR-0086, ADR-0094 and ADR-0138 mention the key or the rule as part of their own records; the pending `.changeset/pre/22161-lint-slice-2-one-line.md` and `.changeset/23224-guest-grants-channel.md` describe the key as it was when they were written. - **The out-of-repo population is NOT MEASURED,** including cloud's stored rows (the stored preview above lists them). --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05ab9ef commit 301eec6

52 files changed

Lines changed: 884 additions & 450 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/plugin-security': patch
5+
---
6+
7+
feat(spec)!: retire the object's `externalSharingModel` — the external org-wide default no principal ever read (#23268)
8+
9+
**BREAKING** — `externalSharingModel` on an object (`ObjectSchema`, the `object` metadata type) is now refused at parse. ADR-0090 D11 declared it as a second org-wide default for portal and partner principals, and nothing ever evaluated it: no request resolves an external principal, and the authorization kernel's `EXTERNAL` rung (ADR-0095 / ADR-0099) admits explicitly shared rows only, never an org-wide default. Setting `externalSharingModel: 'private'` hid nothing from anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share (a manual share or a sharing rule), and an anonymous reader through a share link (`publicSharing`, unchanged).
10+
11+
Clause-②: no (narrowing)
12+
13+
Measured before removal, at `e4ec6ec1a`: no row-pipeline reader in any package. The key's only readers were authoring checks of its own ordering against `sharingModel` — the publish linter's alias and wider-than-internal branches, and the `plugin-security` posture gate's env-tighten comparison — plus the Studio console's object settings select (a separate console change). The only authored instances in this repository were two showcase objects.
14+
15+
## FROM → TO
16+
17+
| you wrote (17.7 and earlier) | write instead |
18+
| --- | --- |
19+
| `externalSharingModel: 'private'` (or `'public_read'`, `'public_read_write'`, `'controlled_by_parent'`) on an object | nothing — delete the key; share rows explicitly where an outside principal needs them |
20+
| `import { SECURITY_EXTERNAL_WIDER } from '@objectstack/lint'` | nothing — the rule `security-external-wider-than-internal` is removed with the key it judged |
21+
22+
**The one-line fix:** delete `externalSharingModel` from every object.
23+
24+
**What an author who still writes it sees.** `tsc` fails at the authoring site (`ObjectSchema` types the key as retired), and the parse — `ObjectSchema.create()`, `defineStack({ objects })`, `PUT /api/v1/meta/object/:name` (draft saves included) — refuses it at its own path with the prescription:
25+
26+
> `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.
27+
28+
## The retirement kit
29+
30+
- **A `retiredKey()` tombstone** on `ObjectSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `data/Object:externalSharingModel`.
31+
- **The D2 conversion `object-external-sharing-model-removed`** (protocol 18, retired from the load path) deletes the key from every `objects[]` entry, one notice per object, as a lossless delete. Studio's object settings offered the select and `PUT /api/v1/meta/object` persisted it until this release, so a stored `object` row can carry it; the rehydration seams replay this conversion over stored rows, and `os migrate meta --stored` lists the rows that carry it before the upgrade.
32+
- **The D3 entry `object-external-sharing-model-retired`** asks the author whether anything they built assumed the key kept outside users away from an object's rows. Nothing ever enforced that.
33+
- **Ledger:** the row goes `planned` → `dead`, kept as a tombstone row (a tombstone keeps the key in the walked shape).
34+
- **No deprecation window**, per the project's startup-stage posture.
35+
36+
**`@objectstack/lint`:** the `security-external-wider-than-internal` rule and its exported `SECURITY_EXTERNAL_WIDER` id are removed, and `security-owd-alias` no longer reads `externalSharingModel`; the parse refuses the key on every parsed door. On an unparsed intake (`os lint` on a raw config) the ledger-driven advisory reports it as `liveness-dead-property`.
37+
38+
**`@objectstack/plugin-security`:** the object posture gate's env-tighten-only rule (R1) compares `sharingModel` alone; the external half left with the key.
39+
40+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for.
41+
42+
<!-- adr-0087: registered object-external-sharing-model-removed, object-external-sharing-model-retired -->

‎content/docs/permissions/authorization.mdx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ site — the file you read when behavior surprises you.
5454
| 1 | **Anonymous deny** | No identity → HTTP 401. **Uniform across every HTTP surface that reaches object data** (#2567): REST `/data` and the metadata endpoints (`/meta`) — the raw-hono standard `/data` routes left the matrix when that duplicate surface was deleted in v17 (#4073), and the dispatcher GraphQL endpoint left when the GraphQL surface was removed (`/graphql` now 404s) — one shared decision, so a caller denied on `/data` can't read the same rows through a sibling door. **Unconditional** (#3963, closing out ADR-0056 D2): the `api.requireAuth: false` opt-out was retired in v17 — the key is tombstoned, so authoring it is a parse error, and anonymous callers are denied on every data surface with no deployment-level escape hatch. Narrower public surfaces do **not** need it — each derives its own authorization from a declaration rather than from the deployment posture: control plane (`/auth`, `/health`, `/discovery`) is allow-listed; public form submission carries a `publicFormGrant` (ADR-0056 Option A); share-links validate their token then read as SYSTEM; and an anonymous **GET** of the book/doc read surface is admitted so `book.audience: 'public'` works under the secure default, with the ADR-0046 §6.7 audience gate — `'public'` only, fail-closed — doing the authorizing (#3963). | `packages/core/src/security/anonymous-deny.ts` `shouldDenyAnonymous` — called by `rest-server.ts` `enforceAuth` and the dispatcher `handleMetadata`/`handleAI` (default in `packages/spec/src/api/rest-server.zod.ts`); a ratchet in `authz-conformance.test.ts` enumerates these entry points from source across a curated file list, and fails CI if a new surface ships ungated in one of those files | fail-closed |
5555
| 2 | **Public-form grant** | An anonymous form submission carries a declaration-derived `publicFormGrant` authorizing ONLY create + read-back on the form's declared target object — never anything else (ADR-0056 Option A). No guest-portal configuration needed (anonymous principals hold the `guest` position). | `packages/plugins/plugin-security/src/security-plugin.ts` (ObjectQL middleware) | scope-limited allow |
5656
| 3 | **Object CRUD** | `allowRead/Create/Edit/Delete` (+ the destructive lifecycle class `allowTransfer/Restore/Purge`, gated ahead of the M2 operations — #1883) resolved across the caller's permission sets. | `packages/plugins/plugin-security/src/permission-evaluator.ts` `checkObjectPermission` | fail-closed 403 |
57-
| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1) plus the external dial (`externalSharingModel`, ADR-0090 D11), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only |
57+
| 4 | **OWD / sharing** | Org-wide default (`private` / `public_read` / `public_read_write` / `controlled_by_parent`; **unset or unknown ⇒ `private`, fail-closed** — ADR-0090 D1), manual record shares, criteria sharing rules (owner-type rules were removed from the authoring surface in v17 rather than left declared-but-skipped — [Sharing Rules](/docs/permissions/sharing-rules#recipient-types)), business-unit hierarchy widening (ADR-0057 D5: scope-depth hierarchy lives on `sys_business_unit`, not positions). | `packages/plugins/plugin-sharing/src/sharing-service.ts` + `sharing-rule-service.ts` | fail-closed to owner-only |
5858
| 5 | **Row-level security** | CEL predicates (`using` read filter, `check` write post-image) compiled into the query. If applicable policies exist but **none of them compiles**, the result is a deny-all sentinel (fail-closed); an object with **no** applicable policy is simply unfiltered at this layer — RLS narrows what the earlier gates already allowed, it never denies on its own. An uncompilable policy alongside compilable ones is excluded from the OR-union **with a logged warning** — exclusion can only narrow access, never widen it. Tenant isolation is a wildcard RLS rule AND-ed on top. | `packages/plugins/plugin-security/src/rls-compiler.ts` + `security-plugin.ts` | fail-closed |
5959
| 6 | **Field-level security** | Read mask (strip non-readable fields) + write deny per `fields` rules. Caller queries that **filter, sort, group, or aggregate by** a non-readable field are rejected outright (HTTP 403, `field_predicate_denied`) — masking only the output would leave row presence as a value oracle. RLS-injected predicates are exempt (they run after the guard and may reference hidden fields like `owner_id`). | `packages/plugins/plugin-security/src/field-masker.ts` + `predicate-guard.ts` | fail-closed on predicates; see posture note below |
6060

@@ -506,8 +506,7 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
506506
save with `422 INVALID_METADATA` and the rule id in `issues`): unset OWD
507507
on custom objects (`security-owd-unset` — an object publish with no
508508
authored `sharingModel` is refused; absence is not a decision, at the CLI
509-
and at the runtime door alike), retired OWD aliases, an external dial
510-
wider than internal (`security-external-wider-than-internal`), `'*'`
509+
and at the runtime door alike), retired OWD aliases, `'*'`
511510
wildcards carrying View/Modify All outside the platform admin set,
512511
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
513512
"role" in security identifiers, a `controlled_by_parent` object without
@@ -526,7 +525,7 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
526525
no lint rule can judge, enforced on every runtime-authored object body —
527526
Studio drafts, REST saves, AI builders. An environment overlay of a
528527
**packaged** object is refused outright, whether it tightens or widens
529-
`sharingModel` / `externalSharingModel`, and with `OS_METADATA_WRITABLE`
528+
`sharingModel`, and with `OS_METADATA_WRITABLE`
530529
set or not (`403 NOT_OVERRIDABLE` — managed content is sealed, ADR-0131
531530
D6): change the posture in the package source and publish instead. The
532531
package door answers ahead of this gate, so its packaged-baseline rule
@@ -535,9 +534,11 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
535534
stored metadata keeps loading unchanged. The gate's former second rule
536535
(`403 owd_external_wider`, external ≤ internal) was **retired as a
537536
duplicate** when the lint block crossed to the runtime door (#8310
538-
maintainer ruling): the 422 lint door answers first for external-wider
539-
and for unauthored-OWD bodies, and the 403 gate remains for
540-
packaged-baseline widening only.
537+
maintainer ruling): the 422 lint door answers first for unauthored-OWD
538+
bodies, and the 403 gate remains for packaged-baseline widening only. The
539+
key that rule ordered, `externalSharingModel`, has since been removed
540+
(ADR-0049): the object schema refuses it with the prescription "delete the
541+
key".
541542
- **Access-matrix snapshot** (ADR-0090 D6, `buildAccessMatrix` /
542543
`diffAccessMatrix`): with `access-matrix.json` committed next to the config,
543544
`os compile` fails on any capability drift with semantic lines

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -122,28 +122,19 @@ only narrows, so it cannot make a `private` row visible. On `public_read` and
122122
> a matching read grant — the rows they admit stay unwritable until the caller
123123
> can also read them. See [RLS — write targets](/docs/permissions/rls#anatomy-of-a-policy).
124124

125-
## The external dial — `externalSharingModel` (ADR-0090 D11)
126-
127-
Portal/partner scenarios get a second, independent dial with the same enum:
128-
129-
```typescript
130-
sharingModel: 'public_read', // internal baseline
131-
externalSharingModel: 'private', // external principals: own + shares only
132-
```
133-
134-
- Defaults to `private`; **external must never be wider than internal**
135-
(`private < public_read < public_read_write`) — the linter errors on a
136-
wider external dial (`security-external-wider-than-internal`).
137-
- "External" is a property of the **principal** (`audience: 'external'`),
138-
never of the object. The BU depth axis does not apply to externals; their
139-
visibility = own records + explicit shares + the external OWD.
140-
- **Status: declared, not yet evaluated at runtime.** Today the dial is
141-
authoring-validated (the linter above) and surfaced in Studio (the `Ext`
142-
badge per object row); the evaluator branch that substitutes it for
143-
external principals lands with the principal-taxonomy semantics phase
144-
(tracked on #2696, liveness: `planned`). Until then no request is
145-
evaluated as external — authoring the dial prepares your model without
146-
changing behavior.
125+
## External principals — no external org-wide default
126+
127+
There is one org-wide default, `sharingModel`. An external (portal or
128+
partner) principal reaches **explicitly shared rows only** — the authorization
129+
kernel's `EXTERNAL` rung (ADR-0095), never derived from an org-wide default.
130+
Grant it a row with a manual share or a sharing rule, or publish a record
131+
through a share link (`publicSharing`).
132+
133+
`externalSharingModel`, the external dial ADR-0090 D11 declared, was removed
134+
in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): no request ever
135+
resolved an external principal, so the value changed no answer. The schema
136+
refuses it with the prescription "delete the key";
137+
`os migrate meta --from 17` lists the mechanical edits for existing sources.
147138

148139
## Criteria-Based Sharing Rules
149140

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,7 +404,7 @@ the only trace is a server-side `WARN` naming the object, the field, and both re
404404

405405
## 5. Sharing Rules & Organization-Wide Defaults
406406

407-
The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of the four canonical values `private`, `public_read`, `public_read_write`, `controlled_by_parent` (ADR-0090 D4 — the legacy aliases `read`/`read_write`/`full` were **removed from the enum**; authoring rejects them). A custom object with no declared `sharingModel` resolves to **`private`** (ADR-0090 D1 — the unset state no longer means public). An optional `externalSharingModel` (same enum, default `private`, never wider than the internal value) is the stricter dial for external portal/partner principals (ADR-0090 D11). **Sharing rules** then grant *additional* access on top of that baseline — sharing only ever widens, RLS only ever narrows.
407+
The baseline visibility of an object is set by its **Organization-Wide Default (OWD)** — the `sharingModel` field on the object (`packages/spec/src/data/object.zod.ts`), one of the four canonical values `private`, `public_read`, `public_read_write`, `controlled_by_parent` (ADR-0090 D4 — the legacy aliases `read`/`read_write`/`full` were **removed from the enum**; authoring rejects them). A custom object with no declared `sharingModel` resolves to **`private`** (ADR-0090 D1 — the unset state no longer means public). There is no separate external default: an external portal/partner principal reaches explicitly shared rows only (ADR-0095). **Sharing rules** then grant *additional* access on top of that baseline — sharing only ever widens, RLS only ever narrows.
408408

409409
```yaml
410410
# account.object.yml

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -969,7 +969,7 @@ Metadata query with filtering, sorting, and pagination
969969
| **searchableFields** | `string[]` | optional | Fields the `$search` query matches against (ADR-0061). Canonical default for the record picker, list quick-search and global search; views may narrow it. When unset, search auto-defaults to the name/title field plus short-text fields. Entries must name a STORED column: a virtual `formula` field is computed on read and materializes no column, so searching it can never match and it is refused — mirror the value onto a stored text field and declare that. |
970970
| **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules |
971971
| **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). |
972-
| **externalSharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | [ADR-0090 D11] OWD for external (portal/partner) principals. Defaults to private; must be &lt;= sharingModel in openness. |
972+
| **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. |
973973
| **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) |
974974
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |
975975
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this object. |

0 commit comments

Comments
 (0)