diff --git a/.changeset/26475-skills-teach-queues-assignment-rules.md b/.changeset/26475-skills-teach-queues-assignment-rules.md new file mode 100644 index 00000000000..829f97df311 --- /dev/null +++ b/.changeset/26475-skills-teach-queues-assignment-rules.md @@ -0,0 +1,12 @@ +--- +'@objectstack/skills': patch +--- + +docs(skills): objectstack-data, -automation and -platform teach ownership queues and assignment rules (ADR-0142 phase 2), one eval each + +Clause-②: no +Metadata-protocol: none + +- **Before.** The `queue` and `assignment_rule` metadata types, the `queues` / `assignmentRules` stack keys, the reserved `queue` column, the claim / release / move verbs, the `queue` sharing recipient and the `queue` approver type shipped in the spec and the docs, but no published skill taught them — `objectstack-platform` listed the two keys without a sentence, and no eval exercised any of it. An agent authoring an app from the skills could route an ownerless record only by hand-rolling it: a per-record picklist, a round-robin counter field, or a hook that picks an owner. +- **Now.** `objectstack-data` gains *Ownership queues — park a record for a team to claim* (the injected `queue` column, a compiled `defineQueue` example, claim / release / move in one sentence each) and `rules/security.md` the `queue` sharing recipient and the `current_user.queues` placeholder; `objectstack-automation` gains *Assignment rules — route a new record to a queue or a person* (a compiled `defineAssignmentRule` example with `criteria`, `assignTo: { type: 'queue' }`, `strategy` and `load`, and the `queue` approver pointer); `objectstack-platform` says what `queues` and `assignmentRules` are and that both need `sharing` in `requires`. One eval per skill asserts the declared shape and refuses the hand-rolled alternatives. +- Every file stays at or under its token ceiling: the new text is paid for by folding sentences the same skill's `rules/**` and `references/**` already hold, and by tightening existing eval model answers. No ceiling moves, no frontmatter changes, no schema, key or behaviour changes. diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index bd634d10ec1..b77e944670f 100644 --- a/skills/objectstack-automation/SKILL.md +++ b/skills/objectstack-automation/SKILL.md @@ -22,11 +22,9 @@ metadata: ## When to Use This Skill -- You are building a **visual flow** (auto-launched, screen, or scheduled). -- You need a **state machine** or **approval process** for a business object. -- You are setting up **event-driven triggers** (record create/update/delete). -- You need **scheduled automation** (daily reports, data cleanup). -- You are **integrating a vendor system** or receiving its callback. +Flows (auto-launched, screen, scheduled), state machines and approvals, +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 @@ -122,14 +120,8 @@ error. **RAW** keys are read as written: a ### Flow Variables `variables` is an **array** of `{ name, type, isInput, isOutput }` entries — -not a name-keyed map; a variable has no `label`: - -```typescript -variables: [ - { name: 'case_id', type: 'text', isInput: true }, // passed in when invoked - { name: 'approval_result', type: 'boolean', isOutput: true }, // returned on completion -], -``` +not a name-keyed map; a variable has no `label`: `variables: [{ name: 'case_id', +type: 'text', isInput: true }, { name: 'approval_result', type: 'boolean', isOutput: true }]`. → [references/examples-flows.md](./references/examples-flows.md) § Flow Example — Auto-Escalate Overdue Cases. @@ -138,35 +130,29 @@ variables: [ > **Handling a failed node: a `fault` edge.** `{ source, target, type: 'fault' }` > routes a failed node to a handler instead of ending the run. **`type: 'fault'` > is what routes — a `label: 'error'` alone does nothing:** an ordinary edge -> traverses on SUCCESS, so the handler would run on success and never on -> failure (`objectstack validate` reports `flow-error-label-not-fault`). -> A handled failure does NOT consume a flow-level `errorHandling.retry`, which -> replays the flow from the start — prefer a fault edge when the failure is -> local. The handler reads `{{ .error }}` (or run-wide -> `{{ $error.message }}`; in CEL, `vars["$error"]`). The run then reports -> success, and the failed step stays in the trace. +> traverses on SUCCESS (`objectstack validate` reports `flow-error-label-not-fault`). +> A handled failure does not consume a flow-level `errorHandling.retry`, which +> replays from the start — prefer a fault edge for a local failure. The handler +> reads `{{ .error }}` (run-wide `{{ $error.message }}`; in CEL, +> `vars["$error"]`); the run then reports success and the failed step stays in the trace. | ROUTES (runtime failure) | Does NOT route (fatal either way) | |:--|:--| | 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'`.** `readonly: true` -> governs the end-user surface: under the default `runAs: 'user'`, the engine -> **strips** a `readonly` field from any non-system write — `create_record` and -> `update_record` alike — the step reports success but the value never lands, -> and the drop is named in the step's warnings. A flow that -> maintains a `readonly` field (approval stamps, conversion flags, SLA -> markers, rollups) must run `runAs: 'system'`. -> `os validate` / `os build` fail a `runAs:'user'` `update_record` that writes -> a `readonly` field, so it surfaces at build time. (`readonlyWhen` fields are -> the same story, per record state — -> flagged as a warning.) Do **not** work around this by removing `readonly`; -> that loses the field's edit protection. - -> **Elevate the write, not the flow.** A `screen` flow stays `runAs: 'user'`. -> When one step in it must write a `readonly` field, move that step into a -> dedicated `runAs: 'system'` flow and call it from a `subflow` node — raising -> the whole flow silently elevates every other write in it. +> **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. + +> **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`, @@ -180,9 +166,9 @@ variables: [ > 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 nothing narrows the query: a scan or rollup with no -> organization predicate reads and writes across every tenant. The tenant column -> is platform-injected — filter on it, never re-declare it per object. +> trigger user, so a scan or rollup with no organization predicate reads and +> writes across every tenant; filter on the platform-injected tenant column, never +> re-declare it per object. > **Who may START a flow: `requiredPermissions: ['…']` on the flow** (ADR-0066 > D4) — every door that starts it by name (trigger route, action of type `flow`, @@ -202,13 +188,11 @@ variables: [ ### Filter operands (`config.filter`) -- **Each operand is a value slot** — a literal, or a CEL value envelope - evaluated before the query (`filter: { id: { dialect: 'cel', source: - 'record.id' } }`). A whole-string **query placeholder** (`{current_user_id}`, - `{today}`, `{current_year_start}`) passes through verbatim for the query - engine to expand; a flow variable never shadows a placeholder. Only `filter` - gets this hand-off — in a text slot `{{ current_year_start }}` is a nonsense - reference. +- **Each operand is a value slot** — a literal or a CEL envelope evaluated + before the query (`filter: { id: { dialect: 'cel', source: 'record.id' } }`). + A whole-string **query placeholder** (`{current_user_id}`, `{today}`, + `{current_year_start}`) passes through verbatim for the query engine; a flow + variable never shadows it, and only `filter` gets this hand-off. - **An operand that evaluates to `null` refuses the node** (not `fault`-routable): a dropped condition does not narrow a query, it widens it, so a routed `delete_record` would empty the object while the run reports @@ -258,13 +242,10 @@ write them right the first time: ❌ `fields: { ref: { dialect: 'cel', source: 'newRec' } }` → writes the whole record object ✅ `source: 'newRec.id'` -3. **`script` nodes call a registered function — that is all they do.** Set - `config.function` to a function registered via - `defineStack({ functions: { my_fn: (ctx) => … } })`. It is **required**: an - empty `script` node refuses at execute; an unregistered function fails loudly. - - There is no other dispatch form: `notify` for delivery, `connector_action` - for a vendor system, a function for logic. +3. **`script` nodes call a registered function — nothing else.** `config.function` + (**required**: an empty node refuses at execute, an unregistered name fails + loudly) names one registered via `defineStack({ functions: { my_fn: (ctx) => … } })`; + `notify` delivers, `connector_action` reaches a vendor, a function computes. **A flow `function` is a PURE compute step — it does NOT read/write the database.** It receives `ctx.input` and **returns** a value; `config.outputVariable` @@ -298,17 +279,9 @@ write them right the first time: } }); ``` -4. **Conditions are bare CEL — the stdlib is what you may call bare.** The table - is **objectstack-formula**'s `CEL_STDLIB_FUNCTIONS`, the bare-callable public - subset; receiver methods (called on a value, never bare) are not in it. - An UNKNOWN function (`PRIOR()`, a typo'd name) and a `{…}`-wrapped field ref - both **fail the build**: a brace is a template, not CEL — write `record.x`, - not `{record.x}`. - -5. **`notify` reports SUCCESS when the `messaging` capability is absent.** The - executor logs `no messaging service registered` and returns success with - `output: { delivered: 0, failed: 0, skipped: true }` and `metrics.acted: 0` — - a green run that delivered nothing. Declare `messaging` in `requires`. +4. **Conditions are bare CEL; only the stdlib (`CEL_STDLIB_FUNCTIONS`, + **objectstack-formula**) is callable bare.** An unknown function (`PRIOR()`, a + typo) and a `{…}`-wrapped field ref both **fail the build** — write `record.x`. --- @@ -316,11 +289,43 @@ write them right the first time: --- +## Assignment rules — route a new record to a queue or a person + +The third automation family: `assignmentRules` on the stack routes each inserted +row of an `ownership: 'user'` object whose `owner_id` and `queue` are both still +empty — once, after every `beforeInsert` hook, never on update, never refusing the +insert (ADR-0142 D5). Entries are ordered; the first whose `criteria` (CEL over +`record.*`) match decides: + + +```typescript +import { defineAssignmentRule } from '@objectstack/spec/automation'; + +export const caseIntake = defineAssignmentRule({ + name: 'case_intake', + object: 'crm_case', + entries: [ + { name: 'urgent', criteria: 'record.priority == "urgent"', + assignTo: { type: 'queue', value: 'escalations' } }, // no strategy: parked for a claim + { name: 'rest', assignTo: { type: 'queue', value: 'support_intake' }, + strategy: 'least_loaded', load: 'record.status != "closed"' }, // or 'round_robin'; `load` only with least_loaded + ], +}); +``` + +`strategy` is for a queue target only; `assignTo: { type: 'user', value: '' }` +names one owner. Queues are declared in `queues` (objectstack-data); an approval +for the same people is `approvers: [{ type: 'queue', value: 'support_intake' }]` +([Approver Types](./references/state-machines-and-approvals.md#approver-types)). +⛔ Never a `beforeInsert` hook that picks an owner, nor a round-robin counter field. + +--- + ## Triggers — Event-Driven Automation -A `record_change` flow fires automatically on a data event. There is **no -standalone trigger object and no top-level `trigger` / `event` key** — the -binding lives entirely in the flow's **`start` node `config`**. +A `record_change` flow fires on a data event; there is **no standalone trigger +object and no top-level `trigger` / `event` key** — the binding is the `start` +node's `config`. ### Prerequisite — declare the capabilities your nodes need @@ -405,25 +410,22 @@ packaged spec, `auth.credentialRef` (the customer's administrator supplies the value) — dispatched by `connector_action`; an inbound vendor callback is an `api` flow. Never a plugin, never an `http` node holding a credential. Recipes, credential hand-off, loader limits: [references/integration-recipes.md](./references/integration-recipes.md). -Platform code only when one holds — and then it is the platform's, filed as -a card; the app still writes none: the protocol is not HTTP; the work writes -identity objects (users, units, positions); it processes files or binaries -locally; one fixed action contract must span several vendors; the callback's -signature is outside the `api` door's HMAC. A document the loader cannot take -yet (YAML, Swagger 2.0, `$ref`, OAuth, request signing) is a gap named against -its platform card, never a reason to write code. +Platform code only when one holds — and then it is the platform's, filed as a +card, the app still writing none: the protocol is not HTTP; the work writes +identity objects (users, units, positions); it processes files locally; one fixed +action contract spans several vendors; the callback's signature is outside the +`api` door's HMAC. A document the loader cannot take yet (YAML, Swagger 2.0, +`$ref`, OAuth, request signing) is a gap named against its platform card, never a +reason to write code. --- ## CRM Automation Blueprint -Default approach — model the business lifecycle in Flow/Approval metadata -first (integrations: above) — in this CRM-style structure: - -| Automation Type | Typical Location | Pattern | -|:--|:--|:--| -| Flow (screen / approval / scheduled) | `src/flows/*.flow.ts` | explicit `variables`, `nodes` + `edges`; approval config in the approvals reference | -| Flow registry | `src/flows/index.ts` | Export `allFlows: Flow[]` and register centrally in `defineStack({ flows })` | +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 })`. --- diff --git a/skills/objectstack-automation/evals/flows-triggers-approvals.json b/skills/objectstack-automation/evals/flows-triggers-approvals.json index 2c7246428fc..b87de50c69f 100644 --- a/skills/objectstack-automation/evals/flows-triggers-approvals.json +++ b/skills/objectstack-automation/evals/flows-triggers-approvals.json @@ -3,8 +3,8 @@ "evals": [ { "id": 1, - "prompt": "Every night at 02:00, mark `subscription` records whose `renewal_date` is in the past as `lapsed`. `status` is a readonly field that only automation may change.", - "expected_output": "Emits a `defineFlow` with `type: 'schedule'`, the cadence on the START NODE's `config.schedule: { type: 'cron', expression: '0 2 * * *' }` (no top-level `schedule`; no cron tag), `runAs: 'system'` because a scheduled run has no trigger user and a readonly field is stripped under `runAs: 'user'`. One `update_record` node with `filter` as an ObjectQL where-map (`renewal_date: { $lt: '{today}' }`) and `fields: { status: 'lapsed' }` (not `values`), `label` on every node, `edges` (never `next`), an `end` node.", + "prompt": "Every night at 02:00, mark `subscription` records whose `renewal_date` has passed as `lapsed`; `status` is a readonly field only automation may change.", + "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"], @@ -13,8 +13,8 @@ }, { "id": 2, - "prompt": "When an opportunity moves into stage `closed_won`, send the account owner an inbox notification with the deal name.", - "expected_output": "Emits a `record_change` flow whose binding lives on the `start` node `config`: `objectName: 'opportunity'`, `triggerType: 'record-after-update'`, and a bare-CEL `condition` `previous.stage != 'closed_won' && record.stage == 'closed_won'` so it fires only on the transition. A `notify` node with `recipients: { dialect: 'cel', source: 'record.owner_id' }`, `title: 'Won: {{ record.name }}'`, `channels: ['inbox']`. `requires` must list `automation`, `triggers` and `messaging`, else notify reports success with `skipped: true`.", + "prompt": "When an opportunity reaches stage `closed_won`, send the account owner an inbox notification with the deal name.", + "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", "{{"], @@ -23,8 +23,8 @@ }, { "id": 3, - "prompt": "Build an autolaunched flow `budget_approval` on the `project` object: when `budget` increases past 100000, route to a manager for approval. The manager can approve, reject, or send the record back to the submitter for revision; after the submitter reworks and resubmits it returns to the manager for another round. Cap it at two send-backs.", - "expected_output": "Emits 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 third `revise` out-edge targeting an `approval_revise` node (no config), and a resubmit edge back into the approval node typed `type: 'back'` — the only thing that legalises the cycle for `registerFlow`. The flow lives in `flows` (there is no `approvals` stack collection); the window is a node, not a re-suspend of the approval node.", + "prompt": "Build an autolaunched flow `budget_approval` on `project`: when `budget` increases past 100000, route to a manager who can approve, reject, or send it back to the submitter for revision; a resubmission returns to the manager. Cap it at two send-backs.", + "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"], @@ -33,8 +33,8 @@ }, { "id": 4, - "prompt": "Build a guided 'new client onboarding' wizard: step 1 collects company details, step 2 asks whether a credit check is needed — if yes show a credit-check screen, otherwise skip it — then create the `account` record.", - "expected_output": "Because the steps BRANCH on logic, this is a `screen` flow (`type: 'screen'`), not a form-view wizard: `screen` nodes for each step, a `decision` node whose out-edges carry mutually exclusive `condition` predicates (an unguarded out-edge always runs, in parallel), a `create_record` node, explicit `variables` entries (`{ name, type, isInput, isOutput }`), and every path reaching an `end`. The flow stays `runAs: 'user'`.", + "prompt": "Build a guided 'new client onboarding' wizard: step 1 collects company details, step 2 asks whether a credit check is needed (if yes, a credit-check screen; else skip), then creates the `account` record.", + "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"], @@ -43,8 +43,8 @@ }, { "id": 5, - "prompt": "Add a step that calls our `pricing.recalc` function to compute the discount and saves it on the quote, then posts the quote to our tax service over HTTP; if the tax call fails, route to a handler node instead of failing the run.", - "expected_output": "Emits a `script` node with `config.function: 'pricing.recalc'` (registered via `defineStack({ functions })`), `inputs`, and `outputVariable: 'recalc'`; a pure compute step, so a following `update_record` persists it with `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.", + "prompt": "Add a step calling our `pricing.recalc` function to compute the discount and save it on the quote, then post the quote to our tax service over HTTP; if the tax call fails, route to a handler node instead of failing the run.", + "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'"], @@ -53,8 +53,8 @@ }, { "id": 6, - "prompt": "When a `job_application` is approved, email the applicant. Applicants are not users; the address is in the record's `applicant_email` field.", - "expected_output": "`notify` with `recipients: { emailField: 'applicant_email' }` (the fourth recipient form: an `email`-typed field of the trigger record) and `channels: 'email'` alone — never a CEL envelope over `record.applicant_email`, which must yield user ids and fails the node for an address no user holds.", + "prompt": "When a `job_application` is approved, email the applicant — not a user; the address is the record's `applicant_email` field.", + "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'"], @@ -63,8 +63,8 @@ }, { "id": 7, - "prompt": "An expense approval only holders of the `expense.approve_request` capability may start; route to the `finance_manager` position, and the submitter must never approve their own expense.", - "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' }]`, sets `excludeSubmitter: true`, and its `onEmptyApprovers` is anything but `auto_approve`, refused beside it.", + "prompt": "An expense approval only holders of `expense.approve_request` may start; route to the `finance_manager` position; the submitter must never approve their own expense.", + "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'"], @@ -73,8 +73,8 @@ }, { "id": 8, - "prompt": "A screen flow: the user uploads the signed contract PDF on the last step, then the flow creates the `contract` record with the file in its `signed_copy` field.", - "expected_output": "A `type: 'screen'` flow whose last `screen` carries `{ name: 'signed_copy', type: 'file', required: true }` and resumes with the uploaded `sys_file` id bound to `signed_copy`; a `create_record` after it writes `fields: { signed_copy: { dialect: 'cel', source: 'signed_copy' } }`, which claims the file for the record.", + "prompt": "A screen flow: the user uploads the signed contract PDF on the last step; the flow then creates the `contract` record with the file in `signed_copy`.", + "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"], @@ -83,8 +83,8 @@ }, { "id": 9, - "prompt": "Build a screen flow `convert_lead` that asks for a company name and a contact name, creates a `crm_account` and a `crm_contact` linked to it, and ends on a screen that shows links to both new records.", - "expected_output": "A `screen` flow (`type: 'screen'`): a first `screen` node collecting `company_name` and `contact_name`; two `create_record` nodes with `outputVariable` (`account`, `contact`), the contact's `fields` linking it with `account: { dialect: 'cel', source: 'account.id' }`; then a last `screen` node whose fields are two `type: 'record_link'` fields, each with `reference` naming its object (`crm_account`, `crm_contact`) and `defaultValue: { dialect: 'cel', source: 'account.id' }` / `{ dialect: 'cel', source: 'contact.id' }`, carrying no `required`, `placeholder` or `options`. The `end` node carries no `message` (refused on a completed end) and the screen `description` holds no markdown link — the links are the `record_link` fields, spelled `record_link`, never the object field type.", + "prompt": "Build a screen flow `convert_lead` that asks for a company name and a contact name, creates a `crm_account` and a linked `crm_contact`, and ends on a screen linking to both new records.", + "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'"], @@ -93,8 +93,8 @@ }, { "id": 10, - "prompt": "The `expense` record page shows an Edit button to every member who can read the record. While an expense is under approval, members who are not on the approval still get the button and their save is refused with `409 RECORD_LOCKED`. Make the page know the record is locked before it offers the edit.", - "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, participant or not — the same judgement the lock hook applies to the write — so the page hides or disables Edit while `locked` is `true`. Not the request row: a non-participant is served none, and request content stays behind the object's `approvalsVisibleToReaders`, which is not switched on for this. A record the caller cannot read answers `404 RECORD_NOT_FOUND`, the same as a missing one.", + "prompt": "The `expense` record page shows Edit to every member who can read the record; while an expense is under approval, non-participants still get the button and their save is refused with `409 RECORD_LOCKED`. Let the page know the record is locked before offering the edit.", + "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"], @@ -103,8 +103,8 @@ }, { "id": 11, - "prompt": "When a `contractor` record is marked `onboarded`, grant its linked user (`contractor.user_id`) the `contractor_portal` permission set. Make the flow run as system so the grant always lands.", - "expected_output": "Not as system: a system-context insert into `sys_user_permission_set` (as into `sys_user_position`, `sys_invitation` or `sys_member`) is refused with `PERMISSION_DENIED` — elevation does not lift a governed identity write, a dedicated system subflow included. Emits the `record_change` flow with `runAs: 'user'`: its `create_record` on `sys_user_permission_set` is judged by the delegated-administration gate as the user whose write fired the flow, recorded as the grant's `granted_by`; a user the gate would not let grant the set is refused, which is the rule, not a defect to route around.", + "prompt": "When a `contractor` is marked `onboarded`, grant its linked user (`contractor.user_id`) the `contractor_portal` permission set; run the flow as system so the grant always lands.", + "expected_output": "Not as system: a system-context insert into `sys_user_permission_set` (as into `sys_user_position`, `sys_invitation` or `sys_member`) is refused with `PERMISSION_DENIED` — elevation does not lift a governed identity write, a system subflow included. The flow runs `runAs: 'user'`: its `create_record` on `sys_user_permission_set` is judged by the delegated-administration gate as the user whose write fired it, recorded as `granted_by`; a user the gate would not let grant the set is refused — the rule, not a defect.", "files": [], "assertions": { "must_contain": [ @@ -119,6 +119,16 @@ "sudo" ] } + }, + { + "id": 12, + "prompt": "Spread new `crm_case` records evenly across the support team; a `refund_requested` case needs sign-off from the support-leads queue.", + "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"] + } } ] } diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index f771da019a2..3513dbc6467 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -289,32 +289,17 @@ column by declaring `storage: { notNull: true }`. CLI details: see ### Naming Rules Summary -| Context | Convention | Example | -|:--------|:-----------|:--------| -| Object `name` | `snake_case` | `project_task` | -| Field keys | `snake_case` | `first_name`, `due_date` | -| Schema properties | `camelCase` | `maxLength`, `lookupFilters` | -| Option `value` | lowercase | `in_progress` | - -See [rules/naming.md](./rules/naming.md) for incorrect/correct examples. +`snake_case` object names and field keys, `camelCase` schema properties, +lowercase option values — incorrect/correct examples: [rules/naming.md](./rules/naming.md). ### Field Type Selection -49 types available. Quick categories: - -- **Text:** `text`, `textarea`, `email`, `url`, `phone`, `password`, `markdown`, `html`, `richtext` — ⚠️ `password` on a generic object is **plaintext at rest** (masked on read, never hashed); prefer `secret` for credentials -- **Secret:** `secret` — reversible, **encrypted-at-rest** credential (DB password, API key, token) via the registered `ICryptoProvider`; masked on read, fail-closed (ADR-0100). The recommended type for credentials -- **Numbers:** `number`, `currency`, `percent` -- **Date/Time:** `date`, `datetime`, `time` -- **Logic:** `boolean`, `toggle` -- **Selection:** `select`, `multiselect`, `radio`, `checkboxes` -- **Relational:** `lookup`, `master_detail`, `tree`, `user` — `user` is a person picker (a lookup specialized to `sys_user`; stored identically to `lookup`) -- **Media:** `image`, `file`, `avatar`, `video`, `audio` -- **Calculated:** `formula`, `summary`, `autonumber` — `formula` fields take a CEL expression in `expression` (use `F\`...\`` from `@objectstack/spec`); see **objectstack-formula** skill -- **Embedded:** `composite`, `repeater`, `record` — embedded JSON sub-objects stored on the parent row (no separate table / FK) -- **Enhanced:** `location`, `address`, `code`, `json`, `color`, `rating`, `slider`, `signature`, `qrcode`, `progress`, `tags`, `vector` - -See [rules/field-types.md](./rules/field-types.md) for full reference. +49 types — the full table, per-type config and traps: [rules/field-types.md](./rules/field-types.md). +From here, three to get right: credentials are `secret` (reversible, **encrypted-at-rest** +via the registered `ICryptoProvider`, masked on read, fail-closed, ADR-0100) — never +`password`, which on a generic object is **plaintext at rest**; `user` is a person picker +(a lookup specialized to `sys_user`, stored identically to `lookup`); `formula` takes CEL in +`expression` (`F\`...\`` from `@objectstack/spec`; see **objectstack-formula**). ### Relationship Patterns @@ -372,18 +357,11 @@ indexes: [ ] ``` -> **`type` and `partial` were retired at protocol 17**: no driver -> ever read either, so an authored `type` chose no access method and an authored -> `partial` produced a full index with the predicate discarded. Both are now a -> `tsc` error and a parse error; `os migrate meta --from 16` lists the edits; -> `--write` applies the ones it can prove, the rest by hand. - -> **A unique index must state its scope** — `'organization'` (one holder per -> organization, NULL-safe) or `'global'` (one holder across the installation). -> On a declared index bare `unique: true` is the deprecated spelling of -> `'global'`: it reads like "per organization" and does the opposite, so `os lint` -> warns and protocol 18 rejects it. On a FIELD, `unique: true` means -> `'organization'` and stays valid. +> `type` and `partial` were **retired at protocol 17** (a `tsc` and parse error; +> `os migrate meta --from 16` lists the edits). A unique index **states its +> scope** — `'organization'` or `'global'`: on a declared index bare `unique: true` +> is the deprecated spelling of `'global'` and protocol 18 rejects it; on a FIELD +> it means `'organization'` and stays valid. See [rules/indexing.md](./rules/indexing.md) for composite indexes, unique scope, and how to build partial / gin / gist indexes at the database layer. @@ -424,6 +402,34 @@ paid `@objectstack/security-enterprise` plugin, **fail closed to `own`** without it, and `defineStack` errors unless the grant declares `requires: ['hierarchy-security']`. Schema: `PermissionSetSchema.objects.*`. +### Ownership queues — park a record for a team to claim + +Every `ownership: 'user'` object carries a platform-injected `queue` column +beside `owner_id` (an object declaring its own `queue` field is refused). A +record **sits in** a queue when `owner_id` is empty and `queue` names it: the +queue's members read and edit it (rows widen, verbs never — no delete). Declare +the queue on the stack; a declared queue's members are positions only, an +organization adds users and teams through `sys_queue_member` rows: + + +```typescript +import { defineQueue } from '@objectstack/spec/security'; + +export const supportIntake = defineQueue({ + name: 'support_intake', // the one name the `queue` column, a rule, an approver and a sharing recipient use + label: 'Support Intake', + members: [{ type: 'position', value: 'service_agent' }], +}); +// defineStack({ queues: [supportIntake], assignmentRules: [...] }) — routing rows in: objectstack-automation +``` + +**Claim** is an ordinary by-id update, `{ owner_id: }`, admitted to a +member while the record is unowned; one writer wins, the other gets `409 +CONCURRENT_UPDATE`. **Release**: the owner writes `owner_id: null` and the record +sits in its home queue again. **Move**: writing another `queue` is a transfer +(`allowTransfer` or `modifyAllRecords`). ⛔ Never a per-record `assigned_to` +picklist, a round-robin counter field, or a hook that picks an owner. + --- → Moved verbatim to [references/examples-objects.md](./references/examples-objects.md) § Metadata Protection (`protection`). diff --git a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json index 7ac2d0eb747..58f183b45d5 100644 --- a/skills/objectstack-data/evals/hooks-security-seeds-datasources.json +++ b/skills/objectstack-data/evals/hooks-security-seeds-datasources.json @@ -3,8 +3,8 @@ "evals": [ { "id": 1, - "prompt": "Support agents want to search tasks by the name of the project they belong to.", - "expected_output": "Applies the one prescription: a stored `text` mirror field `project_name` on `task`, listed in `searchableFields: ['name', 'project_name']`, maintained by a `beforeInsert` / `beforeUpdate` hook on `task` (stamp the parent's name for the incoming `project_id`) and an `afterUpdate` hook on `project` (re-stamp on rename), plus a one-off backfill for rows written outside hooks.", + "prompt": "Support agents want to search tasks by their project's name.", + "expected_output": "A stored `text` mirror `project_name` on `task`, in `searchableFields: ['name', 'project_name']`; a `beforeInsert` / `beforeUpdate` hook on `task` stamps it, an `afterUpdate` hook on `project` re-stamps it on rename; rows written outside hooks get a backfill.", "files": [], "assertions": { "must_contain": ["searchableFields", "project_name", "beforeInsert", "afterUpdate"], @@ -13,8 +13,8 @@ }, { "id": 2, - "prompt": "Sales reps may only see their own leads, sales managers see their unit and everything below it, and every signed-in user can read the `product` catalog.", - "expected_output": "Access is authored in permission sets, not on the object. `lead` gets `sharingModel: 'private'`; the rep set's grant on `lead` carries `readScope: 'own'`, the manager set's `readScope: 'unit_and_below'` — an ADR-0057 scope that needs the enterprise `@objectstack/security-enterprise` plugin and `requires: ['hierarchy-security']`, failing closed to `own` without it. `product` is `sharingModel: 'public_read'`.", + "prompt": "Sales reps see only their own leads, sales managers their unit and everything below it; every signed-in user reads the `product` catalog.", + "expected_output": "Permission sets, not the object: `lead` is `sharingModel: 'private'`; the rep grant carries `readScope: 'own'`, the manager grant `readScope: 'unit_and_below'` (`@objectstack/security-enterprise` + `requires: ['hierarchy-security']`, else fails closed to `own`); `product` is `sharingModel: 'public_read'`.", "files": [], "assertions": { "must_contain": ["sharingModel: 'private'", "readScope", "unit_and_below", "public_read", "hierarchy-security"], @@ -23,8 +23,8 @@ }, { "id": 3, - "prompt": "Expose the existing `customers` table from our Postgres reporting database as a read-only `legacy_customer` object.", - "expected_output": "Declares a `defineDatasource` entry (credentials by `credentialsRef`, never inline) and points the object at it with `datasource: ''`, mapping the physical table via `remoteName` and column names via `columnMap`. Sets the datasource's `schemaMode` so the platform never alters the remote schema, and keeps the external write gate closed.", + "prompt": "Expose the `customers` table of our Postgres reporting database as a read-only `legacy_customer` object.", + "expected_output": "A `defineDatasource` entry (`credentialsRef`, never inline credentials); the object sets `datasource: ''`, `remoteName` for the table, `columnMap` for columns; `schemaMode` leaves the remote schema unaltered; the external write gate stays closed.", "files": [], "assertions": { "must_contain": ["defineDatasource", "datasource", "remoteName", "columnMap", "schemaMode"], @@ -33,8 +33,8 @@ }, { "id": 4, - "prompt": "Seed the `country` reference table for every environment, and add demo `opportunity` rows (dev/test only) that close 45 days after install and are owned by whoever installs the package. A nightly job advances each opportunity's `stage`; replaying the seed must not reset it.", - "expected_output": "Uses `defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data and `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows, with lookups referenced by natural key and dynamic values as CEL: `close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``. Parents before children in the exported array. The job-owned column is declared `columns: { stage: { applyOn: 'create' } }`, so an `upsert` replay leaves it as the row holds it.", + "prompt": "Seed the `country` table for every environment; add demo `opportunity` rows (dev/test only) closing 45 days after install, owned by the installer. A nightly job advances `stage`; a seed replay must not reset it.", + "expected_output": "`defineSeed(Country, { externalId: 'code', mode: 'upsert', records })` for reference data; `defineSeed(Opportunity, { env: ['dev', 'test'], ... })` for demo rows — natural-key lookups, CEL values (`close_date: cel`daysFromNow(45)``, `owner_id: cel`os.user.id``), parents first; `columns: { stage: { applyOn: 'create' } }` so an `upsert` replay leaves the job-owned column.", "files": [], "assertions": { "must_contain": ["defineSeed", "externalId", "upsert", "env:", "daysFromNow", "applyOn: 'create'"], @@ -43,8 +43,8 @@ }, { "id": 5, - "prompt": "Finance may edit a contract's legal terms (`governing_law`, `liability_cap`) while it is a draft, but not once it is approved, signing or active. Legal still edits them.", - "expected_output": "Field permission entries with a row condition on the finance set: `'clm_contract.governing_law': { readable: true, editable: false, when: \"status in ['approved', 'signing', 'active']\" }` and the same for `liability_cap`; the legal set keeps its edit grant.", + "prompt": "Finance may edit a contract's legal terms (`governing_law`, `liability_cap`) while it is a draft, not once approved, signing or active; legal still edits them.", + "expected_output": "Row-conditioned field permission entries on the finance set — `'clm_contract.governing_law': { readable: true, editable: false, when: \"status in ['approved', 'signing', 'active']\" }`, likewise `liability_cap`; legal keeps its edit grant.", "files": [], "assertions": { "must_contain": ["when:", "editable: false", "readable: true", "clm_contract.governing_law"], @@ -53,8 +53,8 @@ }, { "id": 6, - "prompt": "Customers' staff sign in to our portal and may read only the orders of the accounts they belong to; staff of one account must never see another account's orders.", - "expected_output": "Declares the set on the roster object that defines it: `account_member` (`user` field + `account` lookup) with `membershipKeys: { portal_account_ids: { userField: 'user', valueField: 'account' } }`; a portal permission set whose `rowLevelSecurity` reads it (`using: 'account in current_user.portal_account_ids'`), distributed by a position naming it in `permissionSets`; portal staff are invited on the `external` membership tier, outside the `everyone` baseline. Never a policy per account, never an owner stamp on every order.", + "prompt": "Portal users may read only the orders of the accounts they belong to, never another account's.", + "expected_output": "On the roster object `account_member` (`user` + `account` lookup): `membershipKeys: { portal_account_ids: { userField: 'user', valueField: 'account' } }`; a portal permission set reads it (`using: 'account in current_user.portal_account_ids'`), a position names it in `permissionSets`, portal staff join on the `external` tier outside the `everyone` baseline. No policy per account, no owner stamp per order.", "files": [], "assertions": { "must_contain": ["membershipKeys", "current_user.portal_account_ids", "userField", "valueField", "permissionSets", "external"], @@ -63,13 +63,23 @@ }, { "id": 7, - "prompt": "Our app serves English and Chinese users. An `order` whose `amount` exceeds its account's `credit_limit` must not be saved; API clients branch on the code `CREDIT_LIMIT`, and a zh-CN rep reads `超出账户信用额度`.", - "expected_output": "A `beforeInsert` / `beforeUpdate` hook on `order` declares the refusal once, `refusals: { CREDIT_LIMIT: { message: 'Over the account credit limit.' } }`, and its sandboxed `body: { language: 'js', source }` reads the account through `ctx.api` and refuses by code only: `throw Object.assign(new Error(), { code: 'CREDIT_LIMIT' })` — the body's own text is never shown. The zh-CN bundle carries `hooks: { : { refusals: { CREDIT_LIMIT: { message: '超出账户信用额度' } } } }`, served whole in the caller's locale; a catalogue on a hook without a `js` body is refused at save.", + "prompt": "We serve English and Chinese users. An `order` whose `amount` exceeds its account's `credit_limit` must not save; API clients branch on `CREDIT_LIMIT`, a zh-CN rep reads `超出账户信用额度`.", + "expected_output": "A `beforeInsert` / `beforeUpdate` hook on `order` declares `refusals: { CREDIT_LIMIT: { message: 'Over the account credit limit.' } }`; its sandboxed `body: { language: 'js', source }` reads the account via `ctx.api` and refuses by code only (`throw Object.assign(new Error(), { code: 'CREDIT_LIMIT' })`); the zh-CN bundle's `hooks: { : { refusals: { CREDIT_LIMIT: { message: '超出账户信用额度' } } } }` is served in the caller's locale; a catalogue on a hook without a `js` body is refused at save.", "files": [], "assertions": { "must_contain": ["refusals:", "CREDIT_LIMIT: {", "code: 'CREDIT_LIMIT'", "language: 'js'", "hooks: {", "超出账户信用额度"], "must_not_contain": ["new Error('", "handler:"] } + }, + { + "id": 8, + "prompt": "Web-form leads arrive ownerless; inside sales holds them until a rep takes one, and two reps must never own the same lead.", + "expected_output": "`queues: [defineQueue({ name: 'inside_sales', label: 'Inside Sales', members: [{ type: 'position', value: 'inside_sales_rep' }] })]` and an `assignmentRules` entry `assignTo: { type: 'queue', value: 'inside_sales' }` with no `strategy` park each lead in the injected `queue` column — no picklist, counter or insert hook; a rep claims by an ordinary by-id `{ owner_id: }` update, the loser gets `409 CONCURRENT_UPDATE`.", + "files": [], + "assertions": { + "must_contain": ["defineQueue", "type: 'position'", "type: 'queue'", "CONCURRENT_UPDATE"], + "must_not_contain": ["assigned_to", "beforeInsert"] + } } ] } diff --git a/skills/objectstack-data/rules/security.md b/skills/objectstack-data/rules/security.md index 5af7c653ddf..fcfd4a7a83b 100644 --- a/skills/objectstack-data/rules/security.md +++ b/skills/objectstack-data/rules/security.md @@ -20,9 +20,7 @@ export const salesUser = definePermissionSet({ // defineStack({ permissions: [salesUser], ... }) ``` -- `permissionSets:` is **refused at load** with an `Unrecognized key(s) on this - stack definition` error naming the key; `ObjectStackDefinitionSchema` enumerates - the keys. +- `permissionSets:` is **refused at load** (`Unrecognized key(s) on this stack definition`, naming the key). - Bits: `allowCreate` / `allowRead` / `allowEdit` / `allowDelete`, plus `allowTransfer` (ownership change), `viewAllRecords` / `modifyAllRecords` (super-user, bypass sharing). @@ -109,6 +107,7 @@ bridge (warns). The compiler resolves these | `current_user.organization_id` | the caller's tenant | | `current_user.org_user_ids` | ids of users in the same org (for `IN`) | | `current_user.positions` | the caller's positions (for `IN`; ADR-0090 D3) | +| `current_user.queues` | the ownership queues the caller sits in (for `IN`; ADR-0142 D3) | | `current_user.KEY` | the set an object's `membershipKeys.KEY` declares (for `IN`) | - Source: `node_modules/@objectstack/spec/src/security/rls.zod.ts`. @@ -161,7 +160,10 @@ rowLevelSecurity: [ rows grants the key. - **Choose by what the rows are keyed on:** the person → owner scoping; a position → `positions` on the policy; a named record → a membership key; - one record, once → a sharing rule or a manual share. + one record, once → a sharing rule or a manual share; a team's records → a + sharing rule whose `sharedWith: { type: 'queue', value: '' }` + reaches an ownership queue's members (it shares the rule's rows, it parks + none — only the record's `queue` column does that; SKILL.md § Ownership queues). ## Positions grant only what they name; portal users are `external` @@ -213,11 +215,10 @@ fields: { `maskingRule` is **live** (FieldMasker). Two more channels: **Encrypted-at-rest values — `type: 'secret'` (ADR-0100).** For reversible -machine credentials (DB passwords, API keys, tokens): the engine encrypts the -value on write via the registered `ICryptoProvider`, stores the ciphertext -handle in `sys_secret`, persists only an opaque ref on the row, and masks the -value on read. **Fail-closed:** with no crypto provider registered, writes -throw rather than persist cleartext. +machine credentials (DB passwords, API keys, tokens): encrypted on write via the +registered `ICryptoProvider`, the ciphertext handle in `sys_secret` and only an +opaque ref on the row, masked on read. **Fail-closed:** with no crypto provider, +writes throw rather than persist cleartext. ```typescript fields: { @@ -272,12 +273,12 @@ none — e.g. identity tables a plugin writes via its own adapter (adapter-written rows often lack it) is **denied** — the list renders empty. Under `single` the wall is inert. - The `viewAllRecords` superuser bit is **posture-gated and wall-blind**: it - short-circuits **business RLS only**, and only on objects whose posture allows - it (`access.default: 'private'`, `tenancy: { enabled: false }`, or a - better-auth-managed identity table). It never crosses the Layer 0 wall — - crossing takes a *true platform admin* (the superuser bit **and** a - platform-exclusive capability: `manage_metadata`, `manage_platform_settings`, - `studio.access`, `manage_users`) on one of those same postures. + short-circuits **business RLS only**, on objects whose posture allows it + (`access.default: 'private'`, `tenancy: { enabled: false }`, or a + better-auth-managed identity table), and never crosses the Layer 0 wall — + that takes a *true platform admin*: the superuser bit **and** a + platform-exclusive capability (`manage_metadata`, `manage_platform_settings`, + `studio.access`, `manage_users`) on one of those postures. **Recipe — env-global, admin-only object that admins can fully see:** @@ -286,27 +287,21 @@ tenancy: { enabled: false }, // not a tenant object → Layer 0 contributes noth requiredPermissions: ['manage_platform_settings'], // capability AND-gate → members get 403 ``` -> ⚠️ **Both keys are load-bearing — neither works alone.** -> `tenancy: { enabled: false }` *by itself* switches the wall off for **every** -> caller, and any permission set carrying a wildcard (`'*'`) read grant then -> reads every row env-wide — the shipped `viewer_readonly` still carries one, as -> may an app-declared default profile or a customer-authored set. (The -> `member_default` baseline is **not** one of them: it is explicit-allow and -> grants only the objects it names.) `requiredPermissions` *by itself* leaves a -> tenant object, whose untagged rows the wall denies to everyone (above); it is -> the half that holds however permissive the caller's grants are. Posture model: -> ADR-0066. +> ⚠️ **Both keys are load-bearing — neither works alone.** `tenancy: { enabled: +> false }` alone switches the wall off for **every** caller, so any set with a +> wildcard (`'*'`) read grant — the shipped `viewer_readonly`, an app-declared +> default profile, a customer-authored set; not `member_default`, which is +> explicit-allow — reads every row env-wide. `requiredPermissions` alone leaves a +> tenant object whose untagged rows the wall denies to everyone (above); it is +> the half that holds however permissive the grants. Posture model: ADR-0066. ## Renaming or removing a shipped access item -Administrators act on a package's access items by **name**: they switch a -permission set, position or sharing rule off, clone a set, and name its objects, -fields and capabilities in their own sets. A later version can quietly undo them -on upgrade: - -- **Rename** → the old switch-off stops matching; the renamed item arrives switched **on**. -- **Remove** an object, field or capability → a customer's clone or own set names something that no longer exists. -- **Add a field deny** to a shipped set → a customer's clone does not carry it. - -Keep shipped names stable. When such a change is genuinely needed, say so in the -release notes: what changed, and what an administrator should re-check after upgrading. +Administrators act on a package's access items by **name** — switching a +permission set, position or sharing rule off, cloning a set, naming its objects, +fields and capabilities in their own sets — and an upgrade can quietly undo them: +a **rename** stops the old switch-off matching and the item arrives switched +**on**; a **removed** object, field or capability leaves a clone or own set +naming nothing; a **field deny added** to a shipped set is missing from every +clone. Keep shipped names stable; when a change is needed, the release notes say +what changed and what an administrator re-checks after upgrading. diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index f74405b8a72..c9f6588cae7 100644 --- a/skills/objectstack-platform/SKILL.md +++ b/skills/objectstack-platform/SKILL.md @@ -24,13 +24,6 @@ metadata: # Platform — ObjectStack Bootstrap & Plugin System -Two concerns over one `defineStack()` / kernel surface: **project setup** -(`objectstack.config.ts`, drivers, the boot sequence) and **plugin -development** (plugins, services, kernel hook / event handlers, -`ObjectKernel` vs `LiteKernel`). - ---- - ## `defineStack()` — The Core Configuration → Moved verbatim to [references/bootstrap.md](./references/bootstrap.md) § Minimal Example. @@ -54,6 +47,14 @@ state machines are a `state_machine` validation rule on each object. A phantom key like `roles:` or `policies:` is **not** a silent no-op — the top level refuses it and the stack fails to load. +`queues` declares ownership queues (`defineQueue`, `@objectstack/spec/security`; +objectstack-data): named sets of people a new record of an `ownership: 'user'` +object sits in until a member claims it. `assignmentRules` (`defineAssignmentRule`, +`@objectstack/spec/automation`; objectstack-automation) routes each inserted +ownerless row into a queue — parked, `round_robin` or `least_loaded` — or to one +user. Both need the ownership-queue service `@objectstack/plugin-sharing` registers +(`sharing` in `requires`): a stack declaring either without it is refused at boot. + Undeclared keys are handled per surface, and the two postures are worth keeping straight: @@ -133,11 +134,10 @@ manifest: { ``` **`manifest.engines.protocol`:** the metadata-protocol major the app is authored -against. `create-objectstack` stamps it into every project it emits (and all -three example apps carry it). The runtime checks it **before it loads -anything**, so a runtime outside the range refuses the app at the boundary with -the exact migration command instead of crashing later. Change it when you -deliberately move to a new protocol major — never to silence a mismatch. +against (stamped by `create-objectstack`; the example apps carry it). The runtime +checks it **before it loads anything** and refuses an out-of-range app at the +boundary with the exact migration command. Change it when you deliberately move +to a new protocol major — never to silence a mismatch. **Object naming:** The object `name` is the canonical identifier and equals the physical table name. Embed any domain prefix directly in the name (e.g. `name: 'crm_account'`); the object-level `namespace` *field* is retired (ADR-0129 D3) and refused at load. @@ -257,13 +257,9 @@ own plugins). Pick a driver by setting the DB URL, not by writing code. Construct `DriverPlugin` yourself only when **you** own the runtime — embedding via `Runtime` / `ObjectKernel`, or a test that boots a kernel directly: - -```typescript -import { DriverPlugin } from '@objectstack/runtime'; -import { SqlDriver } from '@objectstack/driver-sql'; - -new DriverPlugin(new SqlDriver({ client: 'pg', connection: process.env.DATABASE_URL })); -``` +`new DriverPlugin(new SqlDriver({ client: 'pg', connection: process.env.DATABASE_URL }))` +(`@objectstack/runtime` / `@objectstack/driver-sql`; a full boot: +[Programmatic Bootstrap](./references/bootstrap.md#programmatic-bootstrap-without-cli)). --- @@ -337,10 +333,8 @@ CLI: `os serve` / `os dev` 6. Server listens on the resolved port ``` -**Port resolution** (both `os dev` and `os start` → `os serve`): -`--port` flag › `$OS_PORT` › `$PORT` › `3000`. On a conflict the behaviour is -mode-dependent — dev hops to the next free port, production fails loudly. See -[Ports & networking](./references/operations.md#ports--networking). +**Port resolution** and conflict behaviour (dev hops to the next free port, +production fails loudly): [Ports & networking](./references/operations.md#ports--networking). ### `requires:` — which service plugins boot @@ -421,15 +415,9 @@ and which keys are derived. In particular `object` is derived from the object definition: never write it by hand, and never hand-write a raw `data: [{ object: … }]` literal. -`mode` decides what a seed run does to rows that already exist: - -| Mode | Behavior | -|:-----|:---------| -| `upsert` (default) | Insert or update based on `externalId` match | -| `insert` | Always insert (fails on duplicate) | -| `update` | Only update found records; ignore new ones | -| `ignore` | Insert if not exists, skip otherwise | -| `replace` | ⚠️ **Data loss** — drops and re-inserts all records | +`mode` (`upsert` default · `insert` · `update` · `ignore` · `replace` ⚠️ **data +loss**) decides what a replay does to rows that already exist — +[Import modes](../objectstack-data/references/seeds.md#import-modes). ### Demo accounts — `demoStaff` vs `devLogins` diff --git a/skills/objectstack-platform/evals/config-plugins-ops.json b/skills/objectstack-platform/evals/config-plugins-ops.json index d9c29ce8c9d..599f1e690a8 100644 --- a/skills/objectstack-platform/evals/config-plugins-ops.json +++ b/skills/objectstack-platform/evals/config-plugins-ops.json @@ -3,8 +3,8 @@ "evals": [ { "id": 1, - "prompt": "Write `objectstack.config.ts` for our CRM app: objects, views and flows live under src/, the package namespace is `crm`, and it targets protocol 18.", - "expected_output": "A `defineStack` whose `manifest` carries `id`, `version`, `type: 'app'`, `name`, `namespace: 'crm'` and `engines: { protocol: '^18' }`, with barrel imports and `objects: Object.values(objects)` (same for `views` / `flows`). Object names carry the prefix in `name` (`crm_account`) — no object-level `namespace`, no `tableName`. No driver in `plugins:` (the CLI resolves it from the DB URL) and no `workflows` / `approvals` top-level keys (refused at load).", + "prompt": "Write `objectstack.config.ts` for our CRM app: objects, views and flows under src/, namespace `crm`, protocol 18.", + "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("], @@ -13,8 +13,8 @@ }, { "id": 2, - "prompt": "Build a plugin that registers an `audit` service and records every metadata reload; it should also record every `task` insert.", - "expected_output": "A `Plugin` object with `name`, `init(ctx)` calling `ctx.registerService('audit', ...)` and `ctx.hook('metadata:reloaded', ...)`, `start(ctx)`, `destroy()`. Record-level events are NOT kernel hooks — `ctx.hook('data:beforeInsert')` registers and silently never fires — so the task insert is a data hook (`hooks:` collection / `ql.on('beforeInsert', 'task', ...)`, objectstack-data). No `onInstall` / `onEnable` / `onDisable` plugin hooks exist.", + "prompt": "Build a plugin registering an `audit` service that records every metadata reload and every `task` insert.", + "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 — `ctx.hook('data:beforeInsert')` registers and never fires — so the task insert is a data hook (objectstack-data). No `onInstall` / `onEnable` / `onDisable`.", "files": [], "assertions": { "must_contain": ["registerService", "ctx.hook(", "metadata:reloaded", "init(", "destroy"], @@ -23,8 +23,8 @@ }, { "id": 3, - "prompt": "Our scheduled flows never launch, and a `notify` step reports success but nobody receives anything.", - "expected_output": "Both are missing capability tokens: `requires: ['automation', 'triggers', 'job', 'messaging']` on the stack root — `triggers` alone arms record-change triggers, schedule and time-relative triggers run on the `job` service, and without `messaging` notify returns success with `skipped: true`. Declaring is a demand (a missing provider is a hard boot error); an unknown token is rejected by `defineStack` at authoring time.", + "prompt": "Our scheduled flows never launch, and `notify` reports success but nobody receives anything.", + "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"], @@ -33,8 +33,8 @@ }, { "id": 4, - "prompt": "We deploy on Vercel serverless functions inside our own Hono app. Mount the ObjectStack API under /api.", - "expected_output": "`createHonoApp({ kernel })` from `@objectstack/hono` (prefix defaults to `/api`) mounted into the host app — `HonoServerPlugin` is for when ObjectStack owns the server. Uses `LiteKernel` for the cold-start-sensitive host, warns that `/auth/*` follows the auth `basePath`, not `prefix`, and that there are no `@objectstack/adapter-*` packages.", + "prompt": "We deploy on Vercel serverless functions in our own Hono app; mount the ObjectStack API under /api.", + "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 `@objectstack/adapter-*` packages exist.", "files": [], "assertions": { "must_contain": ["createHonoApp", "@objectstack/hono", "LiteKernel"], @@ -43,8 +43,8 @@ }, { "id": 5, - "prompt": "I changed field types on an object that already has production data. Show me what the database needs, verify the metadata, and give me a quality score.", - "expected_output": "`os migrate plan` to preview the metadata↔DB drift, `os migrate apply` (with `--allow-destructive` for type changes / drops) to reconcile, then `os validate` (schema + CEL + widget bindings) and `os lint --score`. Does not report the change done until `os validate` passes; `os lint` does not replace it.", + "prompt": "I changed field types on an object with production data. Show me what the database needs, verify the metadata and give me a quality score.", + "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"], @@ -54,7 +54,7 @@ { "id": 6, "prompt": "Plan the production deployment: Docker on Kubernetes behind nginx, PostgreSQL.", - "expected_output": "`os start` as the container entrypoint with `PORT` pinned explicitly (production fails loudly on a busy port; only dev auto-hops), `driver-sql` selected by the database URL (no `DriverPlugin` in `plugins:`), TLS terminated on the proxy with the app on an internal port, and `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` / the app `hostname` kept in sync with the proxy. Health via `GET /health` and `GET /ready`.", + "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"], @@ -63,8 +63,8 @@ }, { "id": 7, - "prompt": "Prepare a demo of our contracts app: the seed data assigns matters to 'Legal Counsel 1' and 'Paralegal 1', and the demo should open signed in as Legal Counsel 1.", - "expected_output": "Both people go in `demoStaff` — `name` exactly as the seed references them, `email`, and a `position` / `permissionSets` the stack itself declares — and `os dev` creates the accounts with the seed password (`admin123` by default): no `password` key, no `role:` alias, no platform set such as `admin_full_access`, and no hand-written `sys_user` seed row (it lands as a directory row nobody can sign in as). `devLogins` creates nothing: at most one entry naming Legal Counsel 1's address so the boot banner prints it. Notes that `os build` drops `demoStaff` and a production boot creates no demo account.", + "prompt": "Prepare a demo of our contracts app: the seed assigns matters to 'Legal Counsel 1' and 'Paralegal 1', and the demo opens signed in as Legal Counsel 1.", + "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 like `admin_full_access`, 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"], @@ -73,13 +73,23 @@ }, { "id": 8, - "prompt": "In the contracts demo, Paralegal 1 reports to Legal Counsel 1, so the approval flow's manager step reaches the right person on a fresh `os dev`. Set that up.", - "expected_output": "On Paralegal 1's `demoStaff` entry, `manager: 'counsel1@example.com'` — the `email` of Legal Counsel 1's own entry, never the display name — and nothing else: `os dev` writes `sys_user.manager_id` itself once every demo account exists, and leaves a manager an operator set by hand. No seed row or hook writing `manager_id`, no `reportsTo` / `managerEmail` alias (refused, naming `manager`); a manager naming no entry, the entry itself or a loop is refused when the stack loads.", + "prompt": "In the contracts demo Paralegal 1 reports to Legal Counsel 1; the approval flow's manager step must reach the right person on a fresh `os dev`.", + "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:"] } + }, + { + "id": 9, + "prompt": "Add a `support_intake` queue staffed by the `service_agent` position and route every new `crm_case` into it (`requires: ['automation', 'triggers']`).", + "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"] + } } ] }