From ea6f9bea19746ff5d9396f4c54cbeb4c032a031c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 09:54:36 +0000 Subject: [PATCH 1/2] docs: the ADR-0146 promise pages say what a .custom customization delivers Claude-Session: https://claude.ai/code/session_01J8VVAjSK8WEK7Uja5NRpTo Co-authored-by: Claude --- content/docs/api/metadata-api.mdx | 15 ++++++ content/docs/build-without-code.mdx | 2 +- content/docs/capabilities/integrations.mdx | 2 +- content/docs/concepts/metadata-lifecycle.mdx | 8 +-- .../docs/data-modeling/object-extensions.mdx | 53 ++++++++++++++++++- content/docs/deployment/cli.mdx | 2 + content/docs/deployment/install-modes.mdx | 2 +- content/docs/permissions/permission-sets.mdx | 21 ++++---- 8 files changed, 88 insertions(+), 17 deletions(-) diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index f1c112b04d4..f4cc525f9af 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -96,6 +96,21 @@ unaffected. See [unrecognised type spelling](/docs/api/error-catalog#invalid_req in the error catalog for the envelope — the same code and status cover both this refusal and the misspelling one above; the message text tells them apart. +**An item an installed package ships is customized in that package's pair** +([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) +D2, D11). A `PUT` naming no package onto an item one installed read-only package +`hotcrm` ships is refused `403 NOT_OVERRIDABLE`; for a type an override applies to, +the sentence names the route: save it with `?package=hotcrm.custom`, the body +declaring `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (`basedOn` is the +`manifest.version` of `hotcrm` it was taken from, recorded and never judged). Only the pair declares an override: an item of any other +package that depends on `hotcrm` and bears a `hotcrm` item's name is refused at save +and publish, and its fix is a new name. A permission set, position or capability is +never overridden; it is copied under a new name. What the reads then serve is in +[Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146). +`POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls +it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides, +extensions and switched-off items in one read. + **Body**: `{ type: "object", name: "account", item: { ... } }` **Response**: `{ success: true }` diff --git a/content/docs/build-without-code.mdx b/content/docs/build-without-code.mdx index 7b5a79bdfaf..94558478846 100644 --- a/content/docs/build-without-code.mdx +++ b/content/docs/build-without-code.mdx @@ -35,7 +35,7 @@ Two properties make Studio safe to hand to non-developers: ## Setup — where the platform is administered -The **System Settings** app covers platform administration: user management and invitations, permission assignment, datasources (including federated external databases), diagnostics and audit logs, and the **Marketplace** — template apps such as HotCRM install with one click and appear alongside your own apps. What "customize" means depends on the piece: views and dashboards are yours to edit directly; a packaged object grows through your own fields via an extension package, never by reshaping what shipped; a packaged flow stays locked, but a dedicated **packaged automation** page here lets you switch it off and clone your own — the clone is an ordinary flow, yours to edit in Studio. +The **System Settings** app covers platform administration: user management and invitations, permission assignment, datasources (including federated external databases), diagnostics and audit logs, and the **Marketplace** — template apps such as HotCRM install with one click and appear alongside your own apps. Setup holds no metadata editor: its metadata pages open Studio, and an installed app is customized there. Open the app in Studio and every change lands in its paired customization package (`hotcrm.custom` for HotCRM), created on your first change and exported and deployed like any package, while what shipped stays the vendor's. **Customize** on a view, dashboard, report or flow saves your version as an override of the shipped one, marked with the app it overrides, and **Restore package original** removes it. **Add field** on a shipped object adds your own field, named with a `__c` suffix, and a shipped field's label, description, help text, placeholder and option labels can be changed, nothing else. A shipped flow's switch turns it off for this deployment, and **New similar** copies a flow or a permission set under a new name; a flow's copy starts switched off. ## When to reach for code instead diff --git a/content/docs/capabilities/integrations.mdx b/content/docs/capabilities/integrations.mdx index 17a699dcea1..ff62ad787f8 100644 --- a/content/docs/capabilities/integrations.mdx +++ b/content/docs/capabilities/integrations.mdx @@ -15,7 +15,7 @@ A business system earns its keep by fitting into the systems — and habits — - **Triggers in** — external systems start platform [flows](/docs/capabilities/automation). - **Connectors** — ready-made Slack and generic REST connectors drop into flows; an HTTP API, OpenAPI document, or MCP server can be [declared as metadata](/docs/automation/connectors) and goes live at boot, no plugin code. - **Federated external databases** — point at an existing database and query it in place: no migration, visible in views, usable in flows. -- **Marketplace templates** — install complete apps (HotCRM among them) with one click: objects, views, flows, dashboards, and seed data included. Views and dashboards are yours to edit directly; a packaged flow stays locked, but [Setup](/docs/build-without-code) lets you switch it off and clone your own to edit in Studio; a packaged object grows through your own fields via an extension package, not by editing what shipped. +- **Marketplace templates** — install complete apps (HotCRM among them) with one click: objects, views, flows, dashboards, and seed data included. What shipped stays the vendor's: you customize an installed app in [Studio](/docs/build-without-code), and every change lands in the app's own customization package (`hotcrm.custom` for HotCRM), which exports and deploys to another environment like any package. A view, dashboard, report or flow is customized by overriding it there; a shipped object gains your own fields, named with a `__c` suffix; a shipped flow can be switched off for your deployment, or copied under a new name with **New similar**. - **Deploy anywhere** — cloud or fully self-hosted; your data and your app definition stay yours. ## Everyday collaboration diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index a0d0be72edd..ebe9309c4e4 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -166,13 +166,15 @@ document **whole** for every read that serves the base's name > **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and on a SQL database (SQLite, PostgreSQL, MySQL) still holding one a v18 `os serve` refuses to start until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried such a row to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. +> **ADR-0146 D11 — the tier no longer opens a write door onto a packaged item.** A write naming no package — a save, a publish, a reset (`DELETE`) or a rollback — onto a ✅-type item that one installed read-only package ships is refused `403 NOT_OVERRIDABLE`, and the sentence names the base's pair: save it into `?package=.custom`, declaring `overrides` ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)). A package-less row stored before the close is still served until `os migrate packageless-overlays` moves it into the pair as a declared override; the ✅ row is now the set that ceremony moves and the read path honours for an unmoved row. A package-less write of a name no package ships is environment metadata, and stays open. + | Type | `allowEnvironmentOverlay` | Rationale | | :--- | :---: | :--- | | `view`, `dashboard`, `report`, `email_template`, `translation` | ✅ | Pure rendering / render-time content. Customizing it in an environment is safe. | | `flow` | ❌ | Flows carry execution side-effects (events, jobs, audit), and the `flow` entry declares `supportsOverlay: false` — the loader cannot merge a per-org flow overlay, so the write permission granted a write nothing could read back: an org-scoped flow overlay wrote successfully and **lost its binding on the next cold start**. Rolled back (#6283) so the silent phantom is a loud `403 not_overridable` at the moment of the write. **`allowRuntimeCreate` stays `true`** — a tenant may still author a *brand-new* flow through the runtime API (ADR-0070 package-first authoring); what is closed is overlaying a *packaged* flow. | | `agent` | ❌ | Agents are platform-owned and closed to third parties (ADR-0063 §2) — no environment fork of a packaged agent. | | `permission`, `position` | ❌ | Authorization correctness — an overlay of a packaged permission set *is* silent privilege drift (ADR-0005's security row). Rolled back from an unratified `true` by the 2026-08-08 maintainer ruling (#6483); `position` takes the amendment's `false` default for new types. **`allowRuntimeCreate` stays `true`**: runtime-created sets (including package-bound rows materialized through the metadata door) keep working, while an admin-door edit of a *code-declared* set refuses with `403 not_overridable`, naming the sanctioned path: clone the set under a new name and edit the clone ([ADR-0126](#packaged-flows-actions-and-permission-sets-adr-0126)). The package's own author still changes the declaration in the package and re-publishes (ADR-0086 two-doors). | -| `object` | ❌ | Defines the table schema. Overriding a packaged object would break existing data — but `allowRuntimeCreate: true`, so tenants *can* author brand-new objects, **and this is also how a new field is added** (write the object with the field in `fields`). | +| `object` | ❌ | Defines the table schema. Overriding a packaged object would break existing data — but `allowRuntimeCreate: true`, so tenants *can* author brand-new objects, **and this is also how a new field is added** (write the object with the field in `fields`). On an object an installed package ships, a field is added as an object extension in the base's paired `.custom` package, named `__c` ([Object extensions](/docs/data-modeling/object-extensions#customizing-an-installed-packages-object)). | | `field` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#7893). A field is not a standalone artifact — fields are authored inside their object (`ObjectSchema.fields`), so a `field` write minted a *separate* `sys_metadata` row keyed `('field','.')` that nothing ever composed into the parent: `PUT /meta/field/showcase_task.zz_probe` answered 200 `state=active`, the row read back `_diagnostics.valid: true`, and `GET /meta/object/showcase_task` never listed the field. The door is closed rather than bridged: add a field by writing its **object** (`PUT /meta/object/:name`, or `**/*.object.ts` and redeploy), which both persists and composes. Existing rows are untouched — they were inert before the change too — and remain deletable. | | `datasource` | ❌ | Connection strings; multi-tenant isolation is enforced at a higher layer. (`allowRuntimeCreate: true` — the datasource wizard persists `origin: 'runtime'` rows.) | | `job` | ❌ | **Also `allowRuntimeCreate: false` since protocol 17** (#4509). `JobSchema.handler` names a function in the compiled bundle's function table, which a runtime writer has no way to reach — so a job created in Studio or through `PUT /meta` parsed, saved, reported success and was never scheduled. The door is closed rather than bridged: `job` stays first-class through `*.job.ts` / `defineStack({ jobs, functions })`, where every schedule shape, `retryPolicy` and `timeout` does reach the scheduler. Existing rows are untouched — they were never scheduled — and `migrateStoredMetadata` reports them `skipped`. | @@ -184,7 +186,7 @@ There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectst The gate is **two-tier** — `allowEnvironmentOverlay: false` is not the same as "no runtime writes": -- Overwriting an item that ships from a code package requires `allowEnvironmentOverlay`. Denied → `403 not_overridable`. +- Overwriting an item that ships from a code package is refused → `403 not_overridable`. The change is a declared override saved into the base's paired `.custom` package ([above](#customizing-an-installed-package-the-paired-custom-override-adr-0146)); for a ✅ type, a package-less row stored before ADR-0146 D11 is still served until it is moved. - Creating a brand-new item that has no artifact backing requires `allowRuntimeCreate` (or `allowEnvironmentOverlay`). Denied → `403 not_creatable`. See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0005-metadata-customization-overlay.md) for the full design and amendments. @@ -195,7 +197,7 @@ See [ADR-0005](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/ | Type | Sanctioned path the refusal names | | :--- | :--- | -| `flow` | Clone it under a new name (`POST /api/v1/automation/:name/clone`, body `{name, label}`), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). | +| `flow` | Declare an override from the base's paired `.custom`, or rename it (a write from any other package is told to rename: only the pair declares an override), or switch it off (`POST /api/v1/automation/:name/toggle`, body `{enabled: false}`). The declared override is armed in the base flow's place, keeps its one activation switch, and a run suspended on the base finishes on the version it started on (ADR-0146 D9). A copy under a new name is "New similar", the clone door `POST /api/v1/automation/:name/clone` (body `{name, label}`), which the removal refusal still names. A flow that holds a credential (an inbound hook's `secret`, an `http` node's `signingSecret`) is not cloned in one step: the clone door refuses it with `409`, because a copy would share the secret, and the copy is authored as a new flow with its own. The copy is written switched off, into the source package's paired customization package `.custom` (created on first use), or into the writable package the body's optional `package` names; a flow no read-only package ships has no pair, so its clone names `package`. The answer's `packageId` says where the copy landed ([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) D3, D4). | | `action` | Switch it off (`POST /api/v1/actions/_activation/:object/:action`, body `{enabled: false}`; `:object` is `global` for an object-less action). No clone is offered. | | `permission` | Clone it under a new name: the "Clone" action on the permission set, or the clone door `POST /api/v1/security/permission-sets/:name/clone` (body `{name, label, package?}`) with a new name. The clone door copies the whole definition from the security catalog; a copy of a packaged set lands in the package's paired `.custom` (ADR-0146 D3/D6). | diff --git a/content/docs/data-modeling/object-extensions.mdx b/content/docs/data-modeling/object-extensions.mdx index a35dac5793d..59a65240850 100644 --- a/content/docs/data-modeling/object-extensions.mdx +++ b/content/docs/data-modeling/object-extensions.mdx @@ -19,7 +19,7 @@ DDL, the same forms and list views, the same API. |:---|:---| | You need extra data on an object another package ships (`sys_user`, a CRM package's `account`) | **Object extension** | | You need a new business entity of your own | Define your own [object](/docs/data-modeling/objects) | -| A single tenant needs a field only they use | Tenant customization, not a package extension — an extension ships in code with the package | +| Your deployment needs a field on an object an installed package ships | An object extension in that package's paired `.custom` package, the field named `__c` — [below](#customizing-an-installed-packages-object) | | You need an action, hook, view or page on someone else's object | Declare that artifact at the top level and bind it to the target object — [see below](#what-an-extension-may-not-contribute) | --- @@ -62,7 +62,7 @@ check that spelling first. | Key | Merge behaviour | |:---|:---| -| `fields` | **Additive.** Merged into the target's field map. A name the target already has is **replaced**, not merged. | +| `fields` | **Additive.** Merged into the target's field map. A name the target already has is **replaced**, not merged — never in a `.custom` package, whose added fields all end in `__c`. | | `fieldPatches` | **Applied, field by field.** Changes the *display* of one of the target's own fields — never one an extension adds — without replacing it — [below](#relabelling-a-vendor-field). | | `validations` | **Concatenated.** Your rules run alongside the owner's; neither replaces the other. | | `indexes` | **Concatenated**, same as validations. | @@ -165,6 +165,55 @@ with every package that declares it and the one whose definition is served. Name defensively — prefix added fields with something specific to your package when the name is at all generic. +**The `__c` suffix is reserved to the customer.** A field named `__c` may be +declared only by a base package's paired `.custom` package, below; in any other package, +on its own objects or in its extensions, it is refused, so a vendor's next version can +never take a customer's field name. The save, publish and restore doors, the install +door, `os validate` and `os build` all refuse it, naming the field. + +--- + +## Customizing an installed package's object + +A customer adds to an object an installed package ships — HotCRM's `crm_account` — from +the base's paired customization package `hotcrm.custom` +([ADR-0146](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0146-studio-designs-setup-administers-one-editor-per-item-paired-customize-package.md) +D10). The object is extended, never replaced: + +- **Every field the pair adds to a base object ends in `__c`** (`region__c`); an unsuffixed field in a + `.custom` extension is refused with the suffixed name to use. A field the base + declares is changed only through `fieldPatches`, [above](#relabelling-a-vendor-field). +- **The extension is an item of the pair.** In code it is an `objectExtensions` entry of + the `hotcrm.custom` stack; at runtime it is an `object_extension` item saved into the + pair (`PUT /api/v1/meta/object_extension/crm_account?package=hotcrm.custom`, the body + `ObjectExtensionSchema` with `extend`), drafted, published and exported like any item. + Studio's **Add field** on a read-only base object writes exactly that, creating the + pair on first use. +- **An object takes no `overrides` declaration.** Declaring one on an object is refused, + naming the extension as the route. + +{/* os:check */} +```typescript +import { defineObjectExtension, Field } from '@objectstack/spec/data'; + +export const AccountRegionExtension = defineObjectExtension({ + extend: 'crm_account', + fields: { region__c: Field.text({ label: 'Region' }) }, +}); +``` + +```typescript +export default defineStack({ + manifest: { + id: 'hotcrm.custom', // the base's id + '.custom' + namespace: 'crm_custom', // the base's namespace + '_custom' + dependencies: { hotcrm: '^3' }, // the base, at its installed major + /* … */ + }, + objectExtensions: [AccountRegionExtension], +}); +``` + --- ## Merge order and conflicts diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index cb847613c52..2a62db5b983 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -1104,6 +1104,7 @@ written. | `os migrate files-to-references --apply` | **Refuses** likewise — it rewrites rows, so a concurrent writer is at least as dangerous | | `os migrate meta --stored --apply` | **Refuses** likewise — it rewrites `sys_metadata` rows, and a live process saving metadata is exactly the collision | | `os migrate security-catalog-overlays --apply` | **Refuses** likewise — it deletes `sys_metadata` rows, and a live process saving metadata is exactly the collision | +| `os migrate packageless-overlays --apply` | **Refuses** likewise — it moves `sys_metadata` rows into each base's pair | The check applies to SQLite only: Postgres and MySQL take their own server-side locks. Only same-user processes are visible without elevated privileges, and a @@ -1311,6 +1312,7 @@ where the data lives. | `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent | | `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run | | `os migrate security-catalog-overlays` | List the environment-wide permission-set and position rows (the legacy plurals `permissions` / `positions` included) stored over a name a configured package holds — the rows a v18 cold boot refuses — each with the package that holds its name; `--apply` deletes them, one history tombstone per row. Run it before the first v18 boot, with the flags and environment the deployment boots with: the platform security plugin's sets are held only where `os serve` composes it (an auth secret set, and the `auth` tier on), and the step takes `serve`'s `--preset` and `--dev` with `serve`'s meaning. Adopts nothing; exits 1 while it lists a row | +| `os migrate packageless-overlays` | List the package-less view, dashboard, report, translation and email-template rows stored over a name exactly one installed package ships — the customizations Setup saved before ADR-0146 D11 closed that write — and with `--apply` move each into that package's paired `.custom` (created when absent) as a declared override, `basedOn` the base's installed version; the package-less row is deleted only after the moved one reads back as served. A name no package ships is environment metadata and is not touched; a name several packages ship, or one the pair already holds a row of, is listed and left with the reason. Takes `serve`'s `--preset` and `--dev`. Idempotent: a second run moves nothing. Run it once after upgrading | | `os migrate duplicates` | Report business identifiers already minted twice across the organization partitions, and the rows blocking the boot-time NULL-safe index tightenings — a read-only inventory as JSON on stdout. Renumbers nothing and writes no row and no schema; run it before the boot-time tenancy repair, which overwrites part of the evidence | **The boot itself writes no row and no schema you did not ask for.** Each of these commands boots diff --git a/content/docs/deployment/install-modes.mdx b/content/docs/deployment/install-modes.mdx index 1606bfa8a9e..5963085ba9c 100644 --- a/content/docs/deployment/install-modes.mdx +++ b/content/docs/deployment/install-modes.mdx @@ -13,7 +13,7 @@ picks one of those. |:---|:---|:---| | What is installed | the package, registered as code | a copy of every metadata item, written once into the environment | | Who is the authority | the package | this environment: the copy is its own metadata | -| Editing | nothing it ships is edited in place; you customise beside it | every copied item is edited like anything authored in Studio | +| Editing | nothing it ships is edited in place; a customization is an item of its paired `.custom` package ([how](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146)) | every copied item is edited like anything authored in Studio | | Later versions | install the next version | the copy follows no upgrade | | The vendor's protection locks | kept and enforced | not copied; the install lists each lock it drops | | Tenancy posture | any | refused where the deployment walls organizations (`group`, `isolated`) | diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 617f392ac63..e55c9100361 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -382,8 +382,9 @@ layer — a package's declaration, or a definition authored in this system and saved in `sys_metadata` — served by name from the security catalog. Nothing mirrors it into a row: the `sys_permission_set` record ADR-0094 kept as a projection is retired, its object is no longer registered, and no data -route serves it (ADR-0131 D3). Setup → Access Control → Permission Sets lists -the catalog (`/apps/setup/metadata/permission?scope=environment`), a grant +route serves it (ADR-0131 D3). Studio's All metadata area lists the catalog +(`/studio/~all/metadata/permission?scope=environment`; Setup's old +`/apps/setup/metadata/permission` address redirects there), a grant names its set by name (`sys_user_permission_set.permission_set`), and a write goes through the metadata door (`PUT` or `DELETE /api/v1/meta/permission/:name`), which requires `manage_metadata` — and, under a `group` or `isolated` tenancy @@ -391,13 +392,15 @@ posture, the platform operator. This has three author-visible consequences: - **A set shipped in code cannot be edited in this system.** That is a set an installed package declares (a `*.permission.ts` file, a stack's - `permissionSets`) or a platform default such as `member_default`. Editing it - through Setup or the metadata API answers `403 NOT_OVERRIDABLE` and stores - nothing. Clone it to a new name instead (the **Clone** action on the set, or - the clone door below) and edit the clone: - the clone is your organization's own set, and package upgrades keep reaching - the original. Every other set (one authored in this system, a clone, or - one saved into a writable runtime package) is edited in place: the edit + `permissionSets`) or a platform default such as `member_default`. Saving it + through the metadata API answers `403 NOT_OVERRIDABLE` and stores + nothing. Copy it to a new name instead (**New similar** on the set in + Studio, or the clone door below) and edit the copy: a copy of a set an + installed package ships lands in that package's paired `.custom`, + and package upgrades keep reaching the original. A set is never overridden + under its own name, not even from `.custom`: permission sets, positions and + capabilities hold one name per deployment (ADR-0146 D6). Every other set + (one authored in this system, a clone, or one saved into a writable runtime package) is edited in place: the edit lands on its one stored definition. - **The API name is immutable after creation.** It is the definition's metadata identity, and the metadata door addresses the set by it, so there is no From b91f8e6e8a6f063d0c3d7552e55618acd7312dc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 10:16:57 +0000 Subject: [PATCH 2/2] docs(skills): objectstack-data, -automation and -platform teach the .custom customization path ADR-0146 D2 delivers Claude-Session: https://claude.ai/code/session_01J8VVAjSK8WEK7Uja5NRpTo Co-authored-by: Claude --- .../28577-adr-0146-skills-docs-teaching.md | 12 ++ scripts/check-skills-token-ratchet.mjs | 6 +- skills/objectstack-automation/SKILL.md | 64 +++---- .../evals/flows-triggers-approvals.json | 157 +++++++++++++++--- skills/objectstack-data/SKILL.md | 44 +++-- .../evals/objects-fields-relationships.json | 72 +++++++- .../references/examples-objects.md | 8 +- skills/objectstack-platform/SKILL.md | 37 +---- .../evals/config-plugins-ops.json | 137 ++++++++++++--- 9 files changed, 397 insertions(+), 140 deletions(-) create mode 100644 .changeset/28577-adr-0146-skills-docs-teaching.md diff --git a/.changeset/28577-adr-0146-skills-docs-teaching.md b/.changeset/28577-adr-0146-skills-docs-teaching.md new file mode 100644 index 00000000000..ef57612f641 --- /dev/null +++ b/.changeset/28577-adr-0146-skills-docs-teaching.md @@ -0,0 +1,12 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): the ADR-0126 §1.3 promise pages and the skills teach what ADR-0146 D2 delivers — customizations in `.custom`, `__c` fields, the flow override and "new similar", no package-less overlay + +Clause-②: no +Metadata-protocol: none + +- **Before.** The skills taught the pre-epic paths: `objectstack-data` named no home for a customer's extension of a shipped object and no `__c` rule, and its lock table still called an edit of a shipped item an "overlay"; `objectstack-automation` said nothing about a flow an installed package ships, so an agent cloned it (two copies running) or edited it in place (refused); `objectstack-platform` taught the declared override's read but not who may declare it, how the pair is created, or what the three non-overridable types do. The docs promise pages (`capabilities/integrations`, `build-without-code`) still said views and dashboards are "yours to edit directly" and sent readers to a Setup packaged-automation page to switch off and clone a flow. +- **Now.** `objectstack-data` says an object an installed package ships is extended from the base's paired `.custom` package, every added field ends in `__c`, the suffix is refused anywhere else and an unsuffixed field is refused in the pair, a base field changes only through `fieldPatches`, and Studio's Add field writes the same extension; its lock table reads "Edit", not "Edit (overlay)". `objectstack-automation` says a packaged flow is customized by a same-named declared override in the pair (armed in the base's place, one activation switch, a suspended run finishes on the body it started on), that an undeclared same name is refused at publish, and that the clone door is "new similar": a copy under a new name, switched off, in the pair, never package-less. `objectstack-platform` adds the ensure door and its two manifest facts, the customizations read, the uninstall refusal, the write gate's one rule for dependents (rename), and "new similar" for permission sets, positions and capabilities. One eval per skill asserts the taught shape and refuses the old one. +- Every file stays under its token ceiling: the new text is paid for by deleting restatement in the same file (a layout tree the Template section and the Naming table already state; a search-mirror maintenance table and a formula aside restating the sentence above them; the dates and money legacy → CEL rows `objectstack-formula`'s table holds; the CEL callout restating that skill's pointer; two `runAs` notes and a `readonly` note tightened, no rule removed). The touched ceilings lower to their new measurements; no ceiling rises, no frontmatter, schema or behaviour changes. The docs site's promise pages, install modes, object extensions, metadata lifecycle, permission sets, CLI and metadata API pages gain the same facts. diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index b31e701fe4e..5e8109225b4 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -490,7 +490,7 @@ export const CEILINGS = new Map([ // request signing, which the built-in providers serve (recipe 6 and the // `signed` kind in references/integration-recipes.md). Re-locked AT the // measurement, headroom 0 tokens. - ['skills/objectstack-automation/SKILL.md', 6450], + ['skills/objectstack-automation/SKILL.md', 6449], // 13783 -> 13892 (2026-08-31 app-repo-principles raise, see the block above). // The invariant-vs-transition-gate choice, placed in the field-conditional-rule // bullet list where the tool is actually picked. Both wrong picks are silent: @@ -581,7 +581,7 @@ export const CEILINGS = new Map([ // absorbed and the ceiling moves 5997 -> 6014 (+17), the measured // remainder only. Pinned AT the measurement. // ceil(($(wc -c < skills/objectstack-platform/SKILL.md) - 20) / 4) == ceil(24055 / 4) - ['skills/objectstack-platform/SKILL.md', 6012], + ['skills/objectstack-platform/SKILL.md', 6011], // `skills/objectstack-pm-dispatch/SKILL.md` (9708) and its // `rules/dev-template.md` (1838) left this table on 2026-09-10 with the files: // the maintainer deleted the published PM skill whole (verbatim: @@ -981,7 +981,7 @@ export const CEILINGS = new Map([ // stale `{ type: 'lookup', reference: 'user' }` fields (an object that does // not exist) became `type: 'user'`; -10 tokens, the row follows the // measurement down, zero headroom. - ['skills/objectstack-data/references/examples-objects.md', 1769], + ['skills/objectstack-data/references/examples-objects.md', 1756], ['skills/objectstack-data/references/lint-rules.md', 970], // 1299 -> 1566 (#26297): the `columns` dataset key and `applyOn: 'create'` // — a column a job or an admin owns after the first load is written on diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 985969b41d7..d0fe8c444f1 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -27,11 +27,9 @@ record-change triggers, scheduled automation, vendor integrations and their callbacks, and **assignment rules** that route a new record to a queue or a person. > **Predicates and conditions are CEL** — every `condition` / `guard` / -> `entryCondition` / filter `value` here is an **Expression** envelope evaluated -> by `@objectstack/formula`. A slot takes a plain CEL string; the -> `P\`...\`` / `cel\`...\`` tags wrap the same string with author-time validation. -> Both parse — pick one per file. See -> **objectstack-formula** for the CEL contract, stdlib and legacy → CEL table. +> `entryCondition` / filter `value` is an **Expression** envelope: a plain CEL +> string, or the `P\`...\`` / `cel\`...\`` tag (author-time validation); pick one +> per file. Contract, stdlib, legacy → CEL table: **objectstack-formula**. --- @@ -49,6 +47,19 @@ A **Flow** is a directed graph of nodes — the primary automation building bloc | `record_change` | Fires automatically on record create/update/delete. `autolaunched` + the same `record-*` binding behaves identically; `record_change` also opts into the trigger-readiness lint | | `api` | Inbound **webhook** — needs a start-node `secretRef` (or a literal `secret`; see *Inbound webhook triggers* below); a flow only ever started explicitly is `autolaunched` | +**A flow an installed package ships** is read-only. To change what it does, save +a same-named flow in the base's paired `.custom` package declaring +`overrides: { package: '', basedOn: '' }` +(**objectstack-platform** § Customizing an installed package): it is armed in the +base's place, keeps the base's name and its one activation switch +(`POST /api/v1/automation/:name/toggle`, `{ enabled: false }` = off for this +deployment), and a run suspended on the old body finishes on that body. An +undeclared same-named flow in the pair or in a dependent is refused at publish. +A flow *beside* it is "new similar": `POST /api/v1/automation/:name/clone` +`{ name, label, package? }` lands the copy switched off in the pair, never +package-less; a flow holding a `secret` / `signingSecret` is refused (`409`): author +it anew. + ### Flow Node Types Flows are built from **built-in node types** (plugins register more): @@ -140,29 +151,25 @@ type: 'text', isInput: true }, { name: 'approval_result', type: 'boolean', isOut | 404, rate-limit, rejected write, failed subflow | missing required config key (`objectName`, `url`, `flowName`, `connectorId`/`actionId`); filter operand that evaluated to `null`; graph past the nesting ceiling; unscoped run | > **Writing a `readonly` field? Set `runAs: 'system'`.** Under the default -> `runAs: 'user'` the engine **strips** a `readonly` field from `create_record` -> and `update_record` alike — the step reports success, the value never lands, -> the drop is named in the step's warnings — so a flow maintaining one (approval -> stamps, conversion flags, SLA markers, rollups) runs `runAs: 'system'`. -> `os validate` / `os build` fail a `runAs:'user'` `update_record` writing one -> (`readonlyWhen`: a warning, per record state). Do **not** remove `readonly` -> instead; that loses the field's edit protection. +> `runAs: 'user'` the engine **strips** it from `create_record` / `update_record` +> — the step succeeds, the value never lands, the drop is named in the step's +> warnings — so a flow maintaining one (approval stamps, SLA markers, rollups) +> runs `runAs: 'system'`; `os validate` / `os build` fail a `runAs:'user'` +> `update_record` writing one (`readonlyWhen`: a warning). Do **not** remove +> `readonly` instead. > **Elevate the write, not the flow.** A `screen` flow stays `runAs: 'user'`; a > step that must write a `readonly` field moves into a dedicated `runAs: 'system'` > flow called from a `subflow` node — raising the whole flow silently elevates > every other write in it. > -> **Elevation stops at identity assignment.** A `runAs: 'system'` flow's -> insert, update, delete, transfer, restore or purge of `sys_user_position`, -> `sys_user_permission_set`, `sys_invitation` or `sys_member` is refused with -> `PERMISSION_DENIED`, and moving that step into a dedicated `runAs: 'system'` -> subflow does not lift it: a governed identity write is judged by a door that -> judges a person. Grant positions and permission sets from a flow whose -> `runAs` is `'user'` — the delegated-administration gate then judges the user -> the run acts for and records them as the grant's `granted_by`. Invitations -> and memberships change through the organization endpoints (row 1b, -> https://objectstack.ai/docs/permissions/system-context#1-authorization-and-scoping). +> **Elevation stops at identity assignment.** A `runAs: 'system'` write (insert +> through purge) of `sys_user_position`, `sys_user_permission_set`, +> `sys_invitation` or `sys_member` is refused `PERMISSION_DENIED`, a system +> subflow included. Grant positions and permission sets from a `runAs: 'user'` +> flow — the delegated-administration gate judges that user and records them as +> `granted_by`; invitations and memberships change through the organization +> endpoints (row 1b, https://objectstack.ai/docs/permissions/system-context#1-authorization-and-scoping). > > **A `runAs: 'system'` sweep must pin its organization.** System context has no > trigger user, so a scan or rollup with no organization predicate reads and @@ -219,11 +226,7 @@ write them right the first time: CEL refuses an absent key where the template wrote nothing, so nothing / `null` / a default is now YOUR call; `'{record.tags.0}'` → `'record.tags[0]'`; a deep path guards each step: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` - - money: `source: 'round(x * 100) / 100.0'` — CEL divides two integers as - integers, so `/ 100` turns `123.46` into `123` - - dates: `{TODAY()}` → `isoDate(today())`, `{TODAY() ± N}` → - `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `{NOW()}` → - `isoDatetime(now())`, `{NOW() ± N}` → `isoDatetime(addDays(now(), N))` + - dates and money: the legacy → CEL table in **objectstack-formula** - run user: `{$User.Id}` → `current_user.id`; a flow that can run without a user guards it, `current_user != null ? current_user.id : null`. `current_user` holds `id`, `positions`, `organizationId`, `isPlatformAdmin` @@ -420,10 +423,9 @@ reason to write code. ## CRM Automation Blueprint -Model the business lifecycle in Flow / Approval metadata first (integrations: -above): flows in `src/flows/*.flow.ts` with explicit `variables`, `nodes` + -`edges` (approval config: the approvals reference), exported as `allFlows: Flow[]` -from `src/flows/index.ts` and registered centrally in `defineStack({ flows })`. +Lifecycle first, as Flow / Approval metadata: `src/flows/*.flow.ts` with explicit +`variables`, `nodes` + `edges`, exported from `src/flows/index.ts` into +`defineStack({ flows })`. --- diff --git a/skills/objectstack-automation/evals/flows-triggers-approvals.json b/skills/objectstack-automation/evals/flows-triggers-approvals.json index b87de50c69f..6619a1dcebb 100644 --- a/skills/objectstack-automation/evals/flows-triggers-approvals.json +++ b/skills/objectstack-automation/evals/flows-triggers-approvals.json @@ -7,8 +7,18 @@ "expected_output": "A `defineFlow` with `type: 'schedule'`, the cadence on the START NODE's `config.schedule: { type: 'cron', expression: '0 2 * * *' }` (no top-level `schedule`), `runAs: 'system'` (a readonly field is stripped under `runAs: 'user'`); one `update_record` with a where-map `filter` (`renewal_date: { $lt: '{today}' }`) and `fields: { status: 'lapsed' }` (not `values`), `label` on every node, `edges` (never `next`), an `end` node.", "files": [], "assertions": { - "must_contain": ["defineFlow", "type: 'schedule'", "runAs: 'system'", "update_record", "edges"], - "must_not_contain": ["next:", "values:", "cron`"] + "must_contain": [ + "defineFlow", + "type: 'schedule'", + "runAs: 'system'", + "update_record", + "edges" + ], + "must_not_contain": [ + "next:", + "values:", + "cron`" + ] } }, { @@ -17,8 +27,18 @@ "expected_output": "A `record_change` flow bound on the `start` node `config`: `objectName: 'opportunity'`, `triggerType: 'record-after-update'`, bare-CEL `condition` `previous.stage != 'closed_won' && record.stage == 'closed_won'`; a `notify` node with `recipients: { dialect: 'cel', source: 'record.owner_id' }`, `title: 'Won: {{ record.name }}'`, `channels: ['inbox']`; `requires` lists `automation`, `triggers`, `messaging`, else notify reports success with `skipped: true`.", "files": [], "assertions": { - "must_contain": ["triggerType", "record-after-update", "previous.", "notify", "recipients", "messaging", "{{"], - "must_not_contain": ["trigger:"] + "must_contain": [ + "triggerType", + "record-after-update", + "previous.", + "notify", + "recipients", + "messaging", + "{{" + ], + "must_not_contain": [ + "trigger:" + ] } }, { @@ -27,8 +47,17 @@ "expected_output": "The ADR-0044 shape: an `approval` node (`type: 'approval'`, `approvers: [{ type: 'position', value: 'manager' }]`, `lockRecord: true`, `maxRevisions: 2`) with `approve` / `reject` out-edges plus a `revise` out-edge to an `approval_revise` node, and a resubmit edge back into the approval node typed `type: 'back'` — what legalises the cycle for `registerFlow`. The flow lives in `flows` (no `approvals` collection).", "files": [], "assertions": { - "must_contain": ["type: 'approval'", "approval_revise", "type: 'back'", "maxRevisions", "revise"], - "must_not_contain": ["approvals: [", "maxRevisions: 0"] + "must_contain": [ + "type: 'approval'", + "approval_revise", + "type: 'back'", + "maxRevisions", + "revise" + ], + "must_not_contain": [ + "approvals: [", + "maxRevisions: 0" + ] } }, { @@ -37,8 +66,17 @@ "expected_output": "The steps BRANCH, so a `screen` flow (`type: 'screen'`), not a form-view wizard: a `screen` node per step, a `decision` whose out-edges carry mutually exclusive `condition` predicates (an unguarded out-edge always runs, in parallel), a `create_record`, explicit `variables` entries (`{ name, type, isInput, isOutput }`), every path reaching an `end`; `runAs: 'user'`.", "files": [], "assertions": { - "must_contain": ["screen", "decision", "condition", "create_record", "variables"], - "must_not_contain": ["type: 'wizard'", "next:"] + "must_contain": [ + "screen", + "decision", + "condition", + "create_record", + "variables" + ], + "must_not_contain": [ + "type: 'wizard'", + "next:" + ] } }, { @@ -47,8 +85,19 @@ "expected_output": "A `script` node with `config.function: 'pricing.recalc'` (registered via `defineStack({ functions })`), `inputs` and `outputVariable: 'recalc'` — pure compute, so a following `update_record` persists `fields: { discount: { dialect: 'cel', source: 'recalc.discount' } }`. The `http` node sets `timeoutMs`. Failure routing is a `type: 'fault'` edge; a bare error label routes nothing.", "files": [], "assertions": { - "must_contain": ["function:", "outputVariable", "type: 'fault'", "timeoutMs", "update_record", "dialect: 'cel'"], - "must_not_contain": ["label: 'error'", "query_record", "{recalc"] + "must_contain": [ + "function:", + "outputVariable", + "type: 'fault'", + "timeoutMs", + "update_record", + "dialect: 'cel'" + ], + "must_not_contain": [ + "label: 'error'", + "query_record", + "{recalc" + ] } }, { @@ -57,8 +106,16 @@ "expected_output": "`notify` with `recipients: { emailField: 'applicant_email' }` (the fourth recipient form: an `email` field of the trigger record) and `channels: 'email'` alone — never a CEL envelope over `record.applicant_email`, which must yield user ids and fails for an address no user holds.", "files": [], "assertions": { - "must_contain": ["notify", "emailField", "applicant_email", "'email'"], - "must_not_contain": ["source: 'record.applicant_email'", "channels: ['inbox']"] + "must_contain": [ + "notify", + "emailField", + "applicant_email", + "'email'" + ], + "must_not_contain": [ + "source: 'record.applicant_email'", + "channels: ['inbox']" + ] } }, { @@ -67,8 +124,15 @@ "expected_output": "Flow-level `requiredPermissions: ['expense.approve_request']`, checked at every door that starts the flow (never a run-user positions check inside it); the `approval` node names `approvers: [{ type: 'position', value: 'finance_manager' }]` with `excludeSubmitter: true`, and `onEmptyApprovers` is anything but `auto_approve`, refused beside it.", "files": [], "assertions": { - "must_contain": ["requiredPermissions", "excludeSubmitter: true", "type: 'position'"], - "must_not_contain": ["onEmptyApprovers: 'auto_approve'", "current_user.positions"] + "must_contain": [ + "requiredPermissions", + "excludeSubmitter: true", + "type: 'position'" + ], + "must_not_contain": [ + "onEmptyApprovers: 'auto_approve'", + "current_user.positions" + ] } }, { @@ -77,8 +141,16 @@ "expected_output": "A `type: 'screen'` flow whose last `screen` carries `{ name: 'signed_copy', type: 'file', required: true }` and resumes with the `sys_file` id bound to `signed_copy`; a following `create_record` writes `fields: { signed_copy: { dialect: 'cel', source: 'signed_copy' } }`, claiming the file for the record.", "files": [], "assertions": { - "must_contain": ["type: 'screen'", "type: 'file'", "create_record", "signed_copy"], - "must_not_contain": ["type: 'upload'", "type: 'attachment'"] + "must_contain": [ + "type: 'screen'", + "type: 'file'", + "create_record", + "signed_copy" + ], + "must_not_contain": [ + "type: 'upload'", + "type: 'attachment'" + ] } }, { @@ -87,8 +159,17 @@ "expected_output": "A `type: 'screen'` flow: a `screen` collecting `company_name` and `contact_name`; two `create_record` nodes with `outputVariable` (`account`, `contact`), the contact linked by `account: { dialect: 'cel', source: 'account.id' }`; a last `screen` of two `type: 'record_link'` fields with `reference` (`crm_account`, `crm_contact`) and `defaultValue: { dialect: 'cel', source: 'account.id' }` / `'contact.id'`, no `required`, `placeholder` or `options`. The `end` carries no `message` (refused when completed), the `description` no markdown link — the links are the `record_link` fields, never the object field type.", "files": [], "assertions": { - "must_contain": ["record_link", "reference", "defaultValue", "outputVariable", "dialect: 'cel'"], - "must_not_contain": ["type: 'record'", "]("] + "must_contain": [ + "record_link", + "reference", + "defaultValue", + "outputVariable", + "dialect: 'cel'" + ], + "must_not_contain": [ + "type: 'record'", + "](" + ] } }, { @@ -97,8 +178,14 @@ "expected_output": "Read the lock itself: `client.approvals.getRecordLock('expense', recordId)` (`GET /api/v1/approvals/records/expense/:recordId/lock`) answers `{ locked }` to anyone who can read the record — the lock hook's own judgement — so the page hides Edit while `locked` is `true`. Not the request row: a non-participant is served none, and request content stays behind `approvalsVisibleToReaders`. An unreadable record answers `404 RECORD_NOT_FOUND`, like a missing one.", "files": [], "assertions": { - "must_contain": ["getRecordLock", "locked"], - "must_not_contain": ["lock_record", "approvalsVisibleToReaders: true"] + "must_contain": [ + "getRecordLock", + "locked" + ], + "must_not_contain": [ + "lock_record", + "approvalsVisibleToReaders: true" + ] } }, { @@ -126,8 +213,34 @@ "expected_output": "An assignment rule, not a flow or hook: `defineAssignmentRule({ name: 'case_intake', object: 'crm_case', entries: [{ name: 'all', assignTo: { type: 'queue', value: 'support_team' }, strategy: 'round_robin' }] })` in `assignmentRules` — evaluated once per insert on a still-ownerless row, never on update; no counter field. The sign-off is an `approval` node (`type: 'approval'`) with `approvers: [{ type: 'queue', value: 'support_leads' }]`: the queue's members join the slate at node entry; an empty queue adds nobody.", "files": [], "assertions": { - "must_contain": ["defineAssignmentRule", "strategy: 'round_robin'", "type: 'queue'", "type: 'approval'"], - "must_not_contain": ["assigned_to", "record-after-create"] + "must_contain": [ + "defineAssignmentRule", + "strategy: 'round_robin'", + "type: 'queue'", + "type: 'approval'" + ], + "must_not_contain": [ + "assigned_to", + "record-after-create" + ] + } + }, + { + "id": 13, + "prompt": "HotCRM (`hotcrm`, version 3.2.0) ships the flow `crm_deal_won_alert`. We want it to also create a billing task, with no second copy running beside it. Where does our version live, and how is it switched off if we need to?", + "expected_output": "A flow with the same name `crm_deal_won_alert` in HotCRM's paired package `hotcrm.custom`, its body declaring `overrides: { package: 'hotcrm', basedOn: '3.2.0' }`: it is armed in the base's place (one reload, no window with both bound), keeps the base's name and its one activation switch — `POST /api/v1/automation/crm_deal_won_alert/toggle` with `{ enabled: false }` turns it off for this deployment — and a run suspended on the old body finishes on that body. Not a clone: `POST /api/v1/automation/crm_deal_won_alert/clone` is \"new similar\", a second flow under a new name that lands switched off in the pair; not an undeclared same-named flow (refused at publish); not an edit of HotCRM's own flow.", + "files": [], + "assertions": { + "must_contain": [ + "hotcrm.custom", + "overrides:", + "basedOn", + "/toggle" + ], + "must_not_contain": [ + "_copy", + "beforeUpdate" + ] } } ] diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index 7ad09575038..5bff0fae402 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -144,29 +144,17 @@ declare *that* field searchable. A task list searched by project name: ``` `?search=apollo` expands to `name $contains 'apollo' OR project_name $contains -'apollo'` — one single-table scan, every driver, no traversal. (A `text` mirror -also lands in the auto-default set when the object declares no -`searchableFields`.) - -❌ **Never mirror onto a `formula` field.** A formula field is *virtual* — no -driver materializes a column for it, so a `$contains` predicate against one has -nothing to scan (the SQL driver would emit a `WHERE` over a column that does not -exist). CEL also only reads this record's own fields (`record.`), so a -formula cannot fetch the related title in the first place. The -mistake is **refused, not silent**: a `formula` entry in any `searchableFields` -— the object's own set included — is an `os validate` error -(`searchable-field-unsearchable`), and a request naming one is `400 -INVALID_FIELD`. - -**Mirror maintenance is the trade-off** — a mirror is denormalized data, only as -fresh as whatever writes it. Cover both write paths: - -| When | What maintains the mirror | -|:-----|:--------------------------| -| A task is created, or re-pointed at another project | `beforeInsert` / `beforeUpdate` hook on `task` — read the parent's `name` for the incoming `project_id`, stamp `project_name` | -| A project is renamed | `afterUpdate` hook on `project` — re-stamp `project_name` on that project's tasks | - -Rows written by a path that bypasses hooks (bulk import, direct SQL) need a +'apollo'` — one single-table scan, every driver, no traversal. + +❌ **Never mirror onto a `formula` field.** It is *virtual* (no column to scan) +and CEL reads only this record's own fields, so it cannot hold the related title; +a `formula` entry in any `searchableFields` is refused — `os validate` +`searchable-field-unsearchable`, a request `400 INVALID_FIELD`. + +**Mirror maintenance is the trade-off** — a mirror is only as fresh as whatever +writes it. Cover both paths: a `beforeInsert` / `beforeUpdate` hook on `task` +stamps `project_name` from the incoming `project_id`'s parent; an `afterUpdate` +hook on `project` re-stamps its tasks when it is renamed. Rows written by a path that bypasses hooks (bulk import, direct SQL) need a one-off backfill. See [Lifecycle Hooks](./references/data-hooks.md). --- @@ -376,6 +364,16 @@ or option value the target lacks is refused at install and by `os validate`, and a `picklist`-bound field's options are never patchable. Example: `defineObjectExtension({ extend: 'crm_account', fieldPatches: { industry: { label: 'Sector', options: { technology: { label: 'Tech' } } } } })`. +**An object an installed package ships** (ADR-0146 D10) is extended from the +base's paired `.custom` package (`hotcrm.custom`; **objectstack-platform** +§ Customizing an installed package), never replaced: it takes no `overrides` +declaration. Every field the pair adds ends in `__c` (`region__c`). The suffix is +reserved to the pair, so a `__c` field in any other package, or an unsuffixed +field in a `.custom` extension, is refused at save, publish and install and by +`os validate` / `os build`; a base field changes only through `fieldPatches`. +Studio's **Add field** on a base object writes the same extension, as an +`object_extension` item of the pair. + --- ## Security & Access Control diff --git a/skills/objectstack-data/evals/objects-fields-relationships.json b/skills/objectstack-data/evals/objects-fields-relationships.json index 35e22691b77..9b5fd4b95d0 100644 --- a/skills/objectstack-data/evals/objects-fields-relationships.json +++ b/skills/objectstack-data/evals/objects-fields-relationships.json @@ -7,8 +7,18 @@ "expected_output": "Emits `ObjectSchema.create({...})` with a snake_case `name`, an authored `sharingModel` (a publish with none is refused), `label` / `pluralLabel` / `icon`, `nameField`, and fields with snake_case keys and camelCase config properties: `text` (required), `email`, a `select` whose `options` carry lowercase `value`s (`active`, `inactive`), and BOTH credentials as `type: 'secret'` (encrypted at rest) — never `type: 'password'`, which is plaintext at rest on a generic object. No `namespace` or `tableName` key.", "files": [], "assertions": { - "must_contain": ["ObjectSchema.create", "sharingModel", "type: 'secret'", "options", "nameField"], - "must_not_contain": ["type: 'password'", "namespace:", "vendorName"] + "must_contain": [ + "ObjectSchema.create", + "sharingModel", + "type: 'secret'", + "options", + "nameField" + ], + "must_not_contain": [ + "type: 'password'", + "namespace:", + "vendorName" + ] } }, { @@ -17,8 +27,20 @@ "expected_output": "Line items: `invoice_line` with a `master_detail` field to `invoice`, `required: true`, `deleteBehavior: 'cascade'` (or `'restrict'` — `set_null` is refused on master_detail), `inlineEdit` on that relationship, and a `summary` roll-up field on the parent. Tagging carries attributes, so it is a junction object (`invoice_tag`) with two `lookup` fields plus `added_by` / `added_at` — not a `multiple: true` lookup (an array of ids) and not a `many_to_many` type, which does not exist. The line object is `sharingModel: 'controlled_by_parent'` with `sharing: { controlledByParent: { ownerMayEdit: true } }` (by-id update only; every other write still needs master edit). `added_by` is `type: 'user'` — never a `lookup` to a `user` object, which does not exist.", "files": [], "assertions": { - "must_contain": ["master_detail", "deleteBehavior", "inlineEdit", "summary", "lookup", "ownerMayEdit", "type: 'user'"], - "must_not_contain": ["many_to_many", "deleteBehavior: 'set_null'", "reference: 'user'"] + "must_contain": [ + "master_detail", + "deleteBehavior", + "inlineEdit", + "summary", + "lookup", + "ownerMayEdit", + "type: 'user'" + ], + "must_not_contain": [ + "many_to_many", + "deleteBehavior: 'set_null'", + "reference: 'user'" + ] } }, { @@ -27,8 +49,16 @@ "expected_output": "Uniqueness is an index, not a validation type: `indexes: [{ fields: ['email'], unique: 'organization' }]` (the scope is stated; `type` / `partial` are retired). The lifecycle is one `state_machine` validation with `field: 'status'`, `transitions: { draft: ['active'], active: ['terminated'], terminated: [] }` and a `message`. The date rule is a `script` validation (inverted: fails when the expression is true) or `cross_field`, written as bare CEL over `record.` with explicit `!= null` guards.", "files": [], "assertions": { - "must_contain": ["unique: 'organization'", "state_machine", "transitions", "record."], - "must_not_contain": ["type: 'unique'", "partial:"] + "must_contain": [ + "unique: 'organization'", + "state_machine", + "transitions", + "record." + ], + "must_not_contain": [ + "type: 'unique'", + "partial:" + ] } }, { @@ -37,8 +67,34 @@ "expected_output": "Declares the rules on the FIELD definitions (they belong to the data model, so every form and API write honours them): `visibleWhen: P`record.status == 'paid'``, `requiredWhen: P`record.status == 'paid'`` on `paid_at`, and `readonlyWhen: P`record.status == 'paid'`` on `total`. Bare CEL with `record.` — never brace-wrapped, never Salesforce syntax, never the removed `conditionalRequired` alias, and not in a form view.", "files": [], "assertions": { - "must_contain": ["visibleWhen", "requiredWhen", "readonlyWhen", "record.status"], - "must_not_contain": ["conditionalRequired:", "{record."] + "must_contain": [ + "visibleWhen", + "requiredWhen", + "readonlyWhen", + "record.status" + ], + "must_not_contain": [ + "conditionalRequired:", + "{record." + ] + } + }, + { + "id": 5, + "prompt": "We installed HotCRM (`hotcrm`, namespace `crm`). Add a `region` field to its `crm_account` object for our own use, without touching HotCRM's source.", + "expected_output": "An object extension authored in HotCRM's paired customization package `hotcrm.custom` (never a replacement of the object, never an edit of HotCRM itself): `defineObjectExtension({ extend: 'crm_account', fields: { region__c: Field.text({ label: 'Region' }) } })` registered in that stack's `objectExtensions`. The field name ends in `__c` because the suffix is reserved to the pair; an unsuffixed field in a `.custom` extension is refused at save, publish, install, `os validate` and `os build`. A change to one of HotCRM's own fields goes through `fieldPatches` (display properties only), and the object takes no `overrides` declaration.", + "files": [], + "assertions": { + "must_contain": [ + "hotcrm.custom", + "defineObjectExtension", + "extend: 'crm_account'", + "region__c" + ], + "must_not_contain": [ + "overrides:", + "name: 'crm_account'" + ] } } ] diff --git a/skills/objectstack-data/references/examples-objects.md b/skills/objectstack-data/references/examples-objects.md index a5e21cf0fd4..18867a37ad6 100644 --- a/skills/objectstack-data/references/examples-objects.md +++ b/skills/objectstack-data/references/examples-objects.md @@ -139,7 +139,7 @@ contract**, and the canonical patterns. ## Metadata Protection (`protection`) -Package authors can lock shipped metadata against Studio edits / overlays / deletes. +Package authors can lock shipped metadata against edits / deletes. The `protection` block is **declared on the source schema** (`*.object.ts`, `*.app.ts`, `*.view.ts`, …) and stripped at load time — it never appears in @@ -163,11 +163,11 @@ protection?: { The block is `.strict()`: `reason` is **required** (min 1 / max 500 chars) and unknown keys are rejected. -| `lock` | Edit (overlay) | Delete | Typical use | +| `lock` | Edit | Delete | Typical use | |:---|:---:|:---:|:---| | `none` (default) | ✅ | ✅ | Normal authored metadata | -| `no-overlay` | ❌ | ✅ | Schema is platform-defined but tenant can drop it (e.g. `sys_role`) | -| `no-delete` | ✅ | ❌ | Tenant may customize fields but the object itself must exist | +| `no-overlay` | ❌ | ✅ | Platform-defined schema a deployment may drop (e.g. `sys_role`) | +| `no-delete` | ✅ | ❌ | The object may be extended but must exist | | `full` | ❌ | ❌ | Core admin UI / platform identity (e.g. `sys_user`, `app/setup`) | ### Example diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 0a84d6329f6..44ba3f5099a 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -131,6 +131,8 @@ to a new protocol major — never to silence a mismatch. **Customizing an installed package (ADR-0146):** Customize an installed base `hotcrm` in its paired writable package `hotcrm.custom`, which depends on it: a same-named item there with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` (its `manifest.id`/`manifest.version`) replaces the base's document whole. The read honours it only from the pair (the row's `package_id`), today only for a view, dashboard, report, email_template, translation or flow, never judges `basedOn` and ignores any other package's declaration. An email template is one item per (name, locale): an override names a locale the base ships; a locale the base does not ship is added without `overrides`. Then no `?package`, `?package=hotcrm`, the layered read and both lists serve it; the env-wide list holds one item per name one code package ships (a dependent's stays under its own `?package=`); a pre-D11 unbound Setup row wins over the base's until `os migrate packageless-overlays` moves it, and its package-less save, reset and rollback are refused once movable (D11). The base's address serves the pair's added locale too (both lists, the layered read with `code` null and `effective` the pair's, the execution list, a send in that locale); a locale a dependent adds under the base's name is served only under its own `?package=`, never by a base-named or package-less read. An undeclared pair row or a dependent's row at a locale the base ships is an orphan in the layered read's `overlay`, `effective` = the base's. An override may narrow a public form's anonymous intake, never re-open one any honoured body withdrew. `GET /packages/hotcrm.custom/export` carries it with `overrides`. +The pair comes from `POST /api/v1/packages/hotcrm/ensure-customization`: `namespace` = the base's + `_custom`, `dependencies: { hotcrm: '^' }`; `GET /packages/hotcrm/customizations` lists its overrides, extensions and switched-off items, and uninstalling the base is refused (`409`) while the pair exists. Only the pair declares: the save, publish and install doors, `os validate` and `os build` refuse `overrides` from any other package, and a dependent's item under a base item's name — its one fix is a new name. A permission set, position or capability is never overridden: copy it under a new name ("new similar", landing in the pair). An object is extended (`__c` fields, **objectstack-data**); a flow is overridden or copied (**objectstack-automation**). + --- ## The App / Platform Boundary @@ -181,35 +183,12 @@ The five retired remote templates (`todo`, `compliance`, `content`, ## Project Structure Conventions -Project layout: - -``` -my-app/ -├── objectstack.config.ts # ← THE entry point — defineStack() -├── package.json -├── tsconfig.json -└── src/ - ├── objects/ # Business object definitions - │ ├── task.object.ts # → exports a single object - │ └── index.ts # → barrel: export * from './task.object' - ├── views/ # Optional: UI view definitions - │ ├── task.view.ts - │ └── index.ts - ├── apps/ # Optional: app definitions (nav, pages) - │ ├── main.app.ts - │ └── index.ts - ├── flows/ # Optional: automation flows - │ ├── task.flow.ts - │ └── index.ts - ├── actions/ # Optional: action definitions - │ ├── task.action.ts - │ └── index.ts - ├── dashboards/ # Optional: dashboards - ├── reports/ # Optional: reports - ├── datasets/ # Optional: analytics datasets - ├── i18n/ # Optional: translation bundles - └── handlers/ # Optional: runtime hook handlers -``` +`objectstack.config.ts` at the root is THE entry point (`defineStack()`), beside +`package.json` and `tsconfig.json`. Metadata lives under `src//` — +`objects/` (one `task.object.ts` per object), then the optional `views/`, `apps/`, +`flows/`, `actions/`, `dashboards/`, `reports/`, `datasets/`, `i18n/` and +`handlers/` (runtime hook handlers) — each with a barrel `index.ts` +(`export * from './task.object'`) handed to the stack key of its name. ### Naming Conventions diff --git a/skills/objectstack-platform/evals/config-plugins-ops.json b/skills/objectstack-platform/evals/config-plugins-ops.json index 1193b0bf2d5..2f68bdc9639 100644 --- a/skills/objectstack-platform/evals/config-plugins-ops.json +++ b/skills/objectstack-platform/evals/config-plugins-ops.json @@ -7,8 +7,17 @@ "expected_output": "`defineStack` with a `manifest` of `id`, `version`, `type: 'app'`, `name`, `namespace: 'crm'`, `engines: { protocol: '^18' }`; barrel imports, `objects: Object.values(objects)` (likewise `views` / `flows`); prefixed object names (`crm_account`), no object-level `namespace`, no `tableName`; no driver in `plugins:` (the CLI resolves it); no `workflows` / `approvals` keys (refused).", "files": [], "assertions": { - "must_contain": ["defineStack", "manifest", "engines", "Object.values("], - "must_not_contain": ["workflows:", "approvals:", "tableName:"] + "must_contain": [ + "defineStack", + "manifest", + "engines", + "Object.values(" + ], + "must_not_contain": [ + "workflows:", + "approvals:", + "tableName:" + ] } }, { @@ -17,8 +26,17 @@ "expected_output": "A `Plugin` with `name`, `init(ctx)` calling `ctx.registerService('audit', ...)` and `ctx.hook('metadata:reloaded', ...)`, `start(ctx)`, `destroy()`. Record events are not kernel hooks — a `data:*` kernel hook registers and never fires — so the task insert is a data hook (objectstack-data). No install / enable / disable hooks.", "files": [], "assertions": { - "must_contain": ["registerService", "ctx.hook(", "metadata:reloaded", "init(", "destroy"], - "must_not_contain": ["data:beforeInsert", "onInstall"] + "must_contain": [ + "registerService", + "ctx.hook(", + "metadata:reloaded", + "init(", + "destroy" + ], + "must_not_contain": [ + "data:beforeInsert", + "onInstall" + ] } }, { @@ -27,8 +45,16 @@ "expected_output": "`requires: ['automation', 'triggers', 'job', 'messaging']` on the stack root: `triggers` alone arms record-change triggers, schedule and time-relative ones run on the `job` service, and without `messaging` notify returns success with `skipped: true`. Declaring is a demand (missing provider = hard boot error); `defineStack` rejects an unknown token at authoring time.", "files": [], "assertions": { - "must_contain": ["requires", "job", "messaging", "triggers"], - "must_not_contain": ["plugins: [new ServiceJobPlugin", "@objectstack/adapter-"] + "must_contain": [ + "requires", + "job", + "messaging", + "triggers" + ], + "must_not_contain": [ + "plugins: [new ServiceJobPlugin", + "@objectstack/adapter-" + ] } }, { @@ -37,8 +63,15 @@ "expected_output": "`createHonoApp({ kernel })` from `@objectstack/hono` (prefix defaults to `/api`) in the host app — `HonoServerPlugin` only when ObjectStack owns the server; `LiteKernel` for a cold-start-sensitive host; `/auth/*` follows the auth `basePath`, not `prefix`; no `adapter-*` packages exist.", "files": [], "assertions": { - "must_contain": ["createHonoApp", "@objectstack/hono", "LiteKernel"], - "must_not_contain": ["@objectstack/adapter-", "HonoServerPlugin("] + "must_contain": [ + "createHonoApp", + "@objectstack/hono", + "LiteKernel" + ], + "must_not_contain": [ + "@objectstack/adapter-", + "HonoServerPlugin(" + ] } }, { @@ -47,8 +80,16 @@ "expected_output": "`os migrate plan` previews the drift, `os migrate apply` (`--allow-destructive` for type changes / drops) reconciles, then `os validate` and `os lint --score`; not done until `os validate` passes, which `os lint` does not replace.", "files": [], "assertions": { - "must_contain": ["os migrate plan", "os migrate apply", "os validate", "os lint"], - "must_not_contain": ["os rollback", "os data seed"] + "must_contain": [ + "os migrate plan", + "os migrate apply", + "os validate", + "os lint" + ], + "must_not_contain": [ + "os rollback", + "os data seed" + ] } }, { @@ -57,8 +98,16 @@ "expected_output": "`os start` as the entrypoint, `PORT` pinned (production fails loudly on a busy port), `driver-sql` by database URL (no `DriverPlugin` in `plugins:`), TLS on the proxy, app on an internal port, `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` / app `hostname` in sync; health: `GET /health`, `GET /ready`.", "files": [], "assertions": { - "must_contain": ["os start", "PORT", "OS_TRUSTED_ORIGINS", "driver-sql"], - "must_not_contain": ["new DriverPlugin", "@objectstack/plugin-prometheus"] + "must_contain": [ + "os start", + "PORT", + "OS_TRUSTED_ORIGINS", + "driver-sql" + ], + "must_not_contain": [ + "new DriverPlugin", + "@objectstack/plugin-prometheus" + ] } }, { @@ -67,8 +116,16 @@ "expected_output": "Both go in `demoStaff` — `name` as the seed references them, `email`, a `position` / `permissionSets` the stack declares — and `os dev` creates the accounts with the seed password: no `password` key, no `role` alias, no platform set, no hand-written `sys_user` seed row. `devLogins` creates nothing (at most a banner entry); `os build` drops `demoStaff`; production creates no demo account.", "files": [], "assertions": { - "must_contain": ["demoStaff", "position", "permissionSets", "os dev"], - "must_not_contain": ["role:", "admin_full_access"] + "must_contain": [ + "demoStaff", + "position", + "permissionSets", + "os dev" + ], + "must_not_contain": [ + "role:", + "admin_full_access" + ] } }, { @@ -77,8 +134,15 @@ "expected_output": "On Paralegal 1's `demoStaff` entry, `manager: 'counsel1@example.com'` — Legal Counsel 1's entry `email`, never the display name — and nothing else: `os dev` writes `sys_user.manager_id` once every demo account exists. No seed row or hook writing `manager_id`, no `reportsTo` / `managerEmail` alias (refused); a manager naming no entry, itself or a loop is refused at load.", "files": [], "assertions": { - "must_contain": ["demoStaff", "manager: '", "os dev"], - "must_not_contain": ["manager_id:", "reportsTo:"] + "must_contain": [ + "demoStaff", + "manager: '", + "os dev" + ], + "must_not_contain": [ + "manager_id:", + "reportsTo:" + ] } }, { @@ -87,8 +151,16 @@ "expected_output": "Two stack collections: `queues: [defineQueue({ name: 'support_intake', label: 'Support Intake', members: [{ type: 'position', value: 'service_agent' }] })]` and `assignmentRules: [defineAssignmentRule(...)]` with one entry, `assignTo: { type: 'queue', value: 'support_intake' }`; plus `'sharing'` in `requires`: the keys need `@objectstack/plugin-sharing`'s ownership-queue service (`queue` is the job queue), else the boot is refused.", "files": [], "assertions": { - "must_contain": ["queues:", "assignmentRules:", "defineQueue", "'sharing'"], - "must_not_contain": ["assigned_to", "beforeInsert"] + "must_contain": [ + "queues:", + "assignmentRules:", + "defineQueue", + "'sharing'" + ], + "must_not_contain": [ + "assigned_to", + "beforeInsert" + ] } }, { @@ -97,8 +169,33 @@ "expected_output": "The whole view, under the same name `crm_account_grid`, authored in HotCRM's paired writable package `hotcrm.custom` (which depends on `hotcrm`) with `overrides: { package: 'hotcrm', basedOn: '3.2.0' }` in its body — the base's `manifest.id` and `manifest.version`, `basedOn` recorded and never judged. Every read of the name (no `?package`, `?package=hotcrm`, the layered read, both lists) then serves it under `_packageId: 'hotcrm'`, and `GET /packages/hotcrm.custom/export` carries it with the declaration. Not an in-place edit of HotCRM's own read-only item, not a package-unbound Setup row (that path wins only until the D11 migration), not an object extension (that adds fields and replaces no view), and no declaration from any other package (ignored).", "files": [], "assertions": { - "must_contain": ["hotcrm.custom", "overrides:", "package: 'hotcrm'", "basedOn"], - "must_not_contain": ["PUT /api/v1/meta/view/crm_account_grid", "PUT /meta/view/crm_account_grid"] + "must_contain": [ + "hotcrm.custom", + "overrides:", + "package: 'hotcrm'", + "basedOn" + ], + "must_not_contain": [ + "PUT /api/v1/meta/view/crm_account_grid", + "PUT /meta/view/crm_account_grid" + ] + } + }, + { + "id": 11, + "prompt": "Our partner package `com.acme.crm_addon` depends on `hotcrm` and wants to ship its own `crm_account_grid` view and a narrower `crm_sales_rep` permission set. Can it override HotCRM's?", + "expected_output": "No. Only HotCRM's paired `hotcrm.custom` declares an override (`overrides: { package: 'hotcrm', basedOn }`); a dependent package's item under a base item's name is refused at save, publish and install, by `os validate` and `os build`, and its one fix is a new name — ship the view as `com.acme.crm_addon`'s own `acme_account_grid` and reach it through its own navigation. A permission set is never overridden by anyone, the pair included: copy `crm_sales_rep` under a new name (\"new similar\"; the clone door lands a copy of a packaged set in `hotcrm.custom` unless `package` names a writable package) and narrow the copy.", + "files": [], + "assertions": { + "must_contain": [ + "hotcrm.custom", + "new name", + "new similar" + ], + "must_not_contain": [ + "overrides: { package: 'hotcrm.custom'", + "PUT /api/v1/meta/permission/crm_sales_rep" + ] } } ]