Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/v17-docs-sweep-run-5.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions content/docs/automation/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion content/docs/concepts/metadata-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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":

Expand Down
17 changes: 16 additions & 1 deletion content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading