Skip to content
Merged
16 changes: 16 additions & 0 deletions .changeset/advisory-boot-path-aggregation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/core": minor
"@objectstack/objectql": minor
"@objectstack/metadata-protocol": minor
---

Advisory validation rules no longer flood the startup log, and no longer count a row twice on a clean first boot.

A `severity: 'warning'` (or `'info'`) validation rule is advisory: it never blocks a write, and its message is written for a person filling in a form. Evaluated across a seed load it produced one `WARN` line per row, so a clean-database first boot opened with a wall of form hints re-cast as boot diagnostics — and an app could reach "zero warnings" only by bending its data or deleting the rule.

Two changes, and neither moves what a rule evaluates to:

- **Aggregated reporting on the seed/boot path.** `SeedLoaderService.load()` now runs inside an advisory aggregation scope, and reports one summary line per rule — the rule, the object, the row count, the rule's own message and example rows — instead of one line per row. Off that path (an ordinary interactive write) nothing changes: the same per-write line is emitted verbatim. The new scope is `runWithAdvisoryAggregation` / `recordAdvisoryHit` in `@objectstack/core`.
- **Advisory rules are counted by row, not by write.** An `update` whose payload touches only platform-injected system columns — the shape `claimSeedOwnership` writes when it hands seeded rows to the first admin, `{ owner_id }` — changes no business field, so it no longer re-evaluates the object's advisory rules. Previously a seeded row rang once on insert and again when the claim scan rewrote `owner_id`, so anyone counting startup warnings over-estimated by the number of claimed objects.

`error`-severity rules are untouched by both changes: an invariant is still enforced on every write, whoever issued it and however little it moved. Membership of the "system column" set is resolved per object by `resolveInjectedSystemColumns`, so an object that declares `ownership: 'org'` (no `owner_id`) or `systemFields: false` is judged on its own columns rather than a fixed list.
7 changes: 5 additions & 2 deletions content/docs/data-modeling/validation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,11 @@ All validation types share these base properties:
| Severity | Behavior |
| :--- | :--- |
| `error` | Prevents the record from being saved |
| `warning` | Shows a warning but allows save |
| `warning` | Allows the save; advisory only — logged server-side, not returned to the caller |
| `info` | Informational message, no blocking |

Advisory rules (`warning` / `info`) are reported, never enforced: on an ordinary write each hit is logged as it happens; on a seed/boot load a run's hits are folded into **one summary line per rule**; and an `update` touching only platform-injected system columns does not re-evaluate them at all, so a row is reported once rather than once per write (#13889).

## Validation Types

### Script Validation
Expand All @@ -112,7 +114,8 @@ fix it. Until protocol 17 such a rule was logged at WARN and *skipped*, so the w
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.
`info` rule is logged and does not throw, on the writes where advisory rules are
evaluated at all (see above).

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
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ assuming `isSystem` covers it is a documented source of bugs.

| Assumption | Reality | Anchor |
|:---|:---|:---|
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` |
| "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:2032` (rationale at `:1942`–`1944`, #3760), `flow.zod.ts:702` |
| "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) |
| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10089`–`10106` |
| "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1580` (#3493 / #6640) |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/state-machine.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ transitions: {
- **On insert**, the `transitions` table is not consulted — there is no prior state to transition from. If the rule declares `initialStates`, the created value must be one of them, or the write is rejected with `invalid_initial_state` (the FSM entry point). Without `initialStates`, insert is a no-op and the starting value is constrained only by the field-level `select` option-membership check (`invalid_option`), so any declared option is a legal start.
- **On update**, if the state field changed and the new value is **not** in `transitions[oldValue]`, the write is rejected. Clearing the field (writing `null`) is exempt.
- The check is **lenient where it cannot reason**: if the prior state has no key in `transitions` (legacy or externally-written data, or a state you simply forgot to declare), it does not block. Only an explicit `[]` makes a state a hard dead-end.
- Only a rule with `severity: 'error'` (the default) blocks the write; `warning`/`info` are logged.
- Only a rule with `severity: 'error'` (the default) blocks the write. `warning`/`info` are advisory: logged per write on an ordinary write, folded into **one summary line per rule** on a seed/boot load, and not evaluated at all on an `update` whose payload touches only platform-injected system columns (#13889).
- **Seed writes are exempt** (#3433). Curated seed data — package bootstrap fixtures, marketplace templates, per-org replay, all loaded by `SeedLoaderService` — is a snapshot of established facts, not a record walking its lifecycle, so it bypasses the `state_machine` rule entirely: a seed may be born mid-lifecycle (a `completed` project, a `closed_won` opportunity) and neither `initialStates` (insert) nor `transitions` (update) is enforced. Every *other* validation still runs, so a seed must still satisfy field shape, `format`, `script`, and the rest. `os lint` warns when a seeded value is not a state the machine declares, so a typo is still caught before boot.
- **A "historical" data import is exempt too** (#3479). Migrating established facts — a batch of already-`closed` tickets, `closed_won` deals — is the same "snapshot, not a lifecycle event" situation. Set `treatAsHistorical: true` on the import request (default **off**) and the runner puts `skipStateMachine` on the write context, so `initialStates` doesn't reject those mid-lifecycle rows. A normal import leaves it off and still walks the FSM — the strict behavior is the default, so the exemption is always an explicit opt-in.
- **`treatAsHistorical` also preserves the original audit timeline** (#3493) — **on the rows an import UPDATES** (#6640). Skipping the FSM is only half of migrating established facts; the other half is keeping *when* they happened and *who* did them. Under the same flag the write context also carries `preserveAudit`, which (1) makes `updated_at` / `updated_by` **client-preferred** — a supplied historical last-modified survives instead of being stamped with the import instant — and (2) admits a **whitelist** through the static-`readonly` write strip: the audit/timestamp family plus author-declared business `readonly` fields (`closed_at`, `resolved_by`, …) — but never the record's own primary key (`id`), which is the address of the write rather than a fact being restored (#8215). Platform-managed `system` columns outside that family (`organization_id` and other tenancy/generated columns) stay stripped — a historical import reinstates facts, it does not forge tenancy. Like the FSM exemption this is opt-in: a normal write still auto-stamps `updated_at`/`updated_by` and strips `readonly` exactly as before, and permissions / RLS / field-level security are unchanged.
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ export * from './utils/internal-write-response.js';
// that `@objectstack/metadata-protocol`'s atomic `batchData` also uses.
export * from './utils/migration-journal.js';

// Export the advisory-hit aggregation scope (#13889) — the seam between the
// evaluator that PRODUCES an advisory hit (`@objectstack/objectql`) and the
// machine load path that REPORTS a run's worth of them as one summary
// (`@objectstack/metadata-protocol`'s seed loader). It lives here, on the floor
// both of those already stand on, because neither can import the other's
// package for it: objectql depends on metadata-protocol, so the dependency only
// runs one way.
export * from './utils/advisory-aggregation.js';

// Export the runtime filter-placeholder resolver (framework#3582)
export * from './utils/filter-tokens.js';

Expand Down
183 changes: 183 additions & 0 deletions packages/core/src/utils/advisory-aggregation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Advisory-hit aggregation for machine load paths (#13889).
*
* ## The defect this closes
*
* A `severity: 'warning'` (or `'info'`) validation rule is ADVISORY: it never
* blocks a write, and its message is written for a HUMAN IN A FORM ("At least
* one related record should be selected"). The evaluator's only report channel
* for one is `logger.warn`, one line per (row × violated rule).
*
* On an interactive write that is exactly right. On the seed / bootstrap load
* path it is not: a clean-database first boot writes every seeded row through
* the same evaluator, so an app whose seed legitimately contains N rows that
* trip one advisory rule gets N `WARN` lines in the STARTUP LOG — a form hint
* re-cast as a boot diagnostic, which reads like the boot failed. Measured
* downstream (hotcrm#1203): the only ways an app could reach "zero warnings"
* were to bend its data (attach a parent record it does not have) or delete the
* rule (weaken a real guard). Both are worse than the noise.
*
* Maintainer ruling (2026-09-01, verbatim 「同意」 on option B):
* 「⛔ 不改规则语义,只改日志形状」 — do not change rule semantics, change
* only the shape of the log. So this module changes NOTHING about what a rule
* evaluates to, who it applies to, or whether it blocks. It changes where the
* REPORT goes while a machine load path is running, and nothing else.
*
* ## Why an ambient scope rather than a parameter
*
* The producer (`evaluateValidationRules`, `@objectstack/objectql`) and the
* scope owner (`SeedLoaderService.load`, `@objectstack/metadata-protocol`) are
* separated by the whole engine write path: the loader calls
* `IDataEngine.insert/update`, and the evaluator is reached several layers
* below through a contract that carries no reporting channel. Threading a sink
* through would mean widening `IDataEngine`'s options — a published contract —
* for a diagnostic concern. An ambient scope keeps the change inside the two
* ends that care.
*
* `AsyncLocalStorage` rather than a module-level flag, deliberately: seed loads
* are NOT boot-only. A per-org replay (`sys_organization` insert) runs a full
* seed load on a LIVE server, concurrently with ordinary interactive traffic. A
* plain global would capture those interactive writes' advisories into the
* replay's summary — silently swallowing a report meant for someone else. ALS
* scopes the capture to the load's own async context, which is the difference
* between aggregating and losing.
*
* ## Folded on arrival, never accumulated per row
*
* A hit is folded into its `(object, rule)` group as it arrives, so a load of
* 100 000 rows that all trip one rule holds ONE group, not 100 000 records.
* The group keeps what a reader needs to act — the rule, the object, the
* severity, the message, the row count, and up to {@link ADVISORY_SAMPLE_ROWS}
* example rows — which is the 「详见…」 half of the ruling's summary shape.
*/

import { AsyncLocalStorage } from 'node:async_hooks';

/** Example row references a group carries, so the summary can point at real rows. */
export const ADVISORY_SAMPLE_ROWS = 5;

/** One advisory rule hit, as the evaluator reports it. */
export interface AdvisoryHit {
/** Object the row belongs to. */
object: string;
/** The declared rule's `name`. */
rule: string;
/** The rule's declared severity — `'warning'` or `'info'`; never `'error'`. */
severity: string;
/** The rule's author-written message, in the caller's locale. */
message: string;
/**
* A reference to the row, when the write carries one.
*
* NOT necessarily an id: on the path this exists for — a seed INSERT — the
* driver has not issued an id yet at validation time, so an id-only reference
* would be empty for exactly the case the aggregation was built for. The
* producer sends the best stable handle it has (`id`, else `name=<value>`,
* the same way the seed loader names a row in its own errors).
*/
recordRef?: string;
}

/** Every hit for one `(object, rule)` pair, folded. */
export interface AdvisoryGroup {
object: string;
rule: string;
severity: string;
/** The first message seen for this group (they differ only by interpolation). */
message: string;
/** How many ROWS tripped this rule during the scope. */
rows: number;
/** Up to {@link ADVISORY_SAMPLE_ROWS} example row references. */
sampleRows: string[];
}

interface AdvisoryCollector {
groups: Map<string, AdvisoryGroup>;
}

const storage = new AsyncLocalStorage<AdvisoryCollector>();

/**
* Group key.
*
* `JSON.stringify` of the pair rather than a delimiter-joined string: any
* single-character delimiter is a claim about what an object or rule name
* cannot contain, and a wrong claim collides two groups into one silently.
* Encoding the pair makes the key injective with nothing to be wrong about.
*/
function keyOf(object: string, rule: string): string {
return JSON.stringify([object, rule]);
}

/**
* Offer one advisory hit to the active aggregation scope.
*
* @returns `true` when a scope captured it — the caller must then NOT log its
* own per-row line, because the scope owner reports the whole group. `false`
* when no scope is active, which is the ordinary interactive case: the caller
* logs exactly as it always did. A caller that ignores the return value
* degrades to today's behaviour rather than losing the report.
*/
export function recordAdvisoryHit(hit: AdvisoryHit): boolean {
const collector = storage.getStore();
if (!collector) return false;
const key = keyOf(hit.object, hit.rule);
const existing = collector.groups.get(key);
if (existing) {
existing.rows += 1;
if (hit.recordRef != null && existing.sampleRows.length < ADVISORY_SAMPLE_ROWS) {
existing.sampleRows.push(hit.recordRef);
}
return true;
}
collector.groups.set(key, {
object: hit.object,
rule: hit.rule,
severity: hit.severity,
message: hit.message,
rows: 1,
sampleRows: hit.recordRef != null ? [hit.recordRef] : [],
});
return true;
}

/**
* Whether an advisory aggregation scope is active on this async context.
*
* Exported for tests and for a caller that wants to skip building a message it
* is about to discard; {@link recordAdvisoryHit}'s return value is the one that
* decides.
*/
export function isAggregatingAdvisories(): boolean {
return storage.getStore() !== undefined;
}

/**
* Run `fn` with advisory hits aggregated, then hand the folded groups to
* `report`.
*
* `report` runs in a `finally`, so a load that throws still reports what it
* tripped before failing — the diagnostics of a half-finished seed are the ones
* most worth having. It is called only when there is something to report, and
* its own failure is never allowed to replace the caller's outcome: a reporting
* bug must not turn a successful seed load into a failed one.
*/
export async function runWithAdvisoryAggregation<T>(
fn: () => Promise<T>,
report: (groups: AdvisoryGroup[]) => void,
): Promise<T> {
const collector: AdvisoryCollector = { groups: new Map() };
try {
return await storage.run(collector, fn);
} finally {
if (collector.groups.size > 0) {
try {
report([...collector.groups.values()]);
} catch {
// Reporting is a diagnostic, never an outcome.
}
}
}
}
Loading
Loading