diff --git a/.changeset/v17-docs-sweep-run-5.md b/.changeset/v17-docs-sweep-run-5.md new file mode 100644 index 0000000000..899135be22 --- /dev/null +++ b/.changeset/v17-docs-sweep-run-5.md @@ -0,0 +1,33 @@ +--- +--- + +Docs-only: v17 docs sweep run 5 (rc.2 catch-up over the `2bafe62e..a2ebea2e` +window). Three hand-written pages had drifted behind changes that landed in the +window; the rest of the sweep's search surface came back clean. + +- **`data-modeling/validation.mdx` contradicted itself.** The `has(x)` callout + (added by #4763) says an unevaluable predicate is "rejected fail-closed", while + the `condition` paragraph twelve lines later still taught the pre-17 behaviour — + "logged and skipped rather than blocking the write". That is exactly what #4649 + reversed, and it is the sentence an upgrading author reads to decide whether + their rules are enforcing anything. Rewritten to the shipped contract: + `VALIDATION_FAILED` naming the rule and the offending key, `severity` still + governing blocking, and the total stored-⊕-payload record that makes the + `has()` callout true in the first place. + +- **`automation/hooks.mdx` had no coverage of the declarative `condition` gate** — + one passing clause under wildcard hooks, and nothing else — while three changes + landed on it in this window, one of them breaking. Adds a "The `condition` gate" + section: an unevaluable condition now ABORTS the operation instead of silently + skipping the hook (#4775), the condition evaluates against stored ⊕ payload + rather than the write's payload alone (#4770), and `previous` is bound so a + condition can express a transition (#4784) — including the upgrade note that + `record.x == v` alone is now true on every update of an already-matching row. + +- **`concepts/metadata-lifecycle.mdx` did not list `job`**, which is the page that + explains the two-tier overlay/runtime-create gate and therefore where an author + looks when "create job" disappears from Studio. Adds the row with #4509's + reasoning (`handler` names a compiled-bundle function a runtime writer cannot + reach), and notes that the standalone `validation` kind is gone under ADR-0088. + +Releases nothing. diff --git a/content/docs/automation/hooks.mdx b/content/docs/automation/hooks.mdx index c3e4de45c1..a45c79e4c7 100644 --- a/content/docs/automation/hooks.mdx +++ b/content/docs/automation/hooks.mdx @@ -96,6 +96,46 @@ a hook can be, so review it as such: Where a wildcard is the honest answer, say so in `description` — it is the one place a reviewer can find out why the broad target was chosen. +## The `condition` gate + +A declarative hook can carry a CEL `condition`, evaluated **before** the handler: +the hook fires only when it is true. Three things about it changed in protocol 17, +and the first is breaking. + +**An unevaluable condition aborts the operation (#4775).** A condition the platform +could not work out used to emit a `logger.warn` and `return false` — the hook simply +did not fire. "The condition said no" and "the platform could not evaluate the +condition" carry *opposite* risks depending on the hook: swallowed into a `before*` +guard it silently lets the write through; swallowed into an audit hook it silently +drops the record. They are now distinct outcomes, and the second **fails the write**. +Hooks that have been getting by on that skip will start failing — that is how you +find out they were never enforcing anything. + +**The condition reads the record, not the payload (#4770).** It used to evaluate +against `ctx.input.data` — only the fields the current write happened to carry — so +`condition: "record.done == true"` did **not** run on the most ordinary updates there +are (change the status, change the assignee), because `done` was not in the payload. +It now evaluates against **stored ⊕ payload**: the prior record overlaid with this +write's data, total over the object's declared fields (`null` for a declared field in +neither), with the payload winning for the fields it carries. Undeclared or typo'd +keys stay unresolvable — `record.stauts` is an error, not a silent `false`. + +**`previous` is bound, so a condition can express a transition (#4784).** The scope +was a single `{ record }` root, which made the published `previous` form +(`previous.status != 'escalated' && record.status == 'escalated'`, and the legacy +`OLD.x` / `ISCHANGED(x)` mappings) abort with `No such key: previous`. It is now +bound alongside `record`, built by the same helper the validation side uses, so one +CEL expression means one thing on both surfaces. + +```ts +// "after a task transitions to done" — not "whenever a done task is written" +condition: P`previous.done != true && record.done == true` +``` + +Because `record` now means the record's *state*, `record.done == true` alone is true +on **every** update of an already-done row. If you wrote a condition under the old +payload semantics expecting "the write that changed it", add the `previous` half. + ## Before Hook Mutate the incoming record before it is saved. The engine exposes the pending diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index abb5deab70..00fb883650 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -113,8 +113,9 @@ In shared-database multi-tenancy, **most metadata types must not be per-org cust | `permission`, `position` | ✅ | Per-org overlays are allowed; tenant-level controls layer on top. | | `object`, `field` | ❌ | Defines the table schema. Overriding a packaged object/field would break existing data — but both set `allowRuntimeCreate: true`, so tenants *can* author brand-new objects and fields. | | `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`. | -There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), record state machines are a `state_machine` validation). The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. +There is no `workflow` metadata type (per [ADR-0020](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0020-state-machine-converge-and-enforce.md), record state machines are a `state_machine` validation). Nor is there a standalone `validation` type any more — it was retired in protocol 17 under [ADR-0088](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0088-metadata-kind-admission-and-retirement.md) because `ValidationRuleSchema` carries no object-binding key, so a rule authored through that door could never say what it protected; author rules in the object's own `validations[]` instead. The runtime gate is implemented in `OVERLAY_ALLOWED_TYPES` (derived from the registry) and enforced by `SysMetadataRepository.put()`. The gate is **two-tier** — `allowOrgOverride: false` is not the same as "no runtime writes": diff --git a/content/docs/data-modeling/validation.mdx b/content/docs/data-modeling/validation.mdx index b0f9d27e84..9a229a2aff 100644 --- a/content/docs/data-modeling/validation.mdx +++ b/content/docs/data-modeling/validation.mdx @@ -104,7 +104,22 @@ Formula-based validation using expressions: } ``` -The `condition` is a **CEL** predicate and should evaluate to `true` when the data is **invalid**. A predicate that cannot be evaluated (parse error, unbound variable) is treated as a broken rule — it is logged and skipped rather than blocking the write. +The `condition` is a **CEL** predicate and should evaluate to `true` when the data is **invalid**. + +A predicate that cannot be evaluated (parse error, unbound variable, a comparison +CEL has no overload for) **rejects the write** with `VALIDATION_FAILED`, naming the +rule and — when the fault is a missing key — the key the predicate read and how to +fix it. Until protocol 17 such a rule was logged at WARN and *skipped*, so the write +went through while the rule stayed declared and enforced nothing; a validation exists +to reject a write, and "the rule could not be checked" must never resolve to +"allowed" (#4649). `severity` still governs blocking — an unevaluable `warning` / +`info` rule is logged and does not throw. + +The record a predicate reads is the stored row overlaid with this write's payload, +**total over the object's declared fields** (`null` for a declared field present in +neither), on update as well as insert — so a driver that stores only the columns it +wrote no longer decides whether an expression is evaluable. That totality is also why +`has()` is not a null guard: see the callout above. ### Uniqueness (use an index, not a validation rule)