Skip to content

Commit b3efeb7

Browse files
qq9340100claude
andauthored
fix(spec): Field.autonumber declares readonly: true (#5628) (#6646)
* fix(spec): Field.autonumber declares readonly: true (#5628) `FieldSchema.readonly` is a two-part contract: "never editable in forms" AND server-enforced on both write paths. #5503 closed the server half for `autonumber` BY TYPE — a caller-supplied record number is stripped before any driver sees it, flag or no flag. The form half is keyed on the FLAG, and the builder never set it, so a renderer deciding editability from `field.readonly` drew an editable "record number" input whose value the server was already guaranteed to discard. `Field.autonumber(...)` now emits `readonly: true`, applied AFTER the author's config so it cannot be spread away, with the authoring type narrowed to `readonly?: true` — `Field.autonumber({ readonly: false })` is a compile error at the metadata site rather than a silently coerced value. A hand-written `{ type: 'autonumber' }` literal is unaffected: it is covered by the by-type server enforcement, which never depended on the flag. Measured side effect, neutralized in the same change: the DataProtocol create ingress (`stripReadonlyForInsert`, #3043) knows only the `isSystem` exemption, while the engine's runtime-owned strip also honours `preserveAudit` (#3493). With the flag present the ingress would have deleted a historical import's legacy record number BEFORE the engine could keep it — and no test would have gone red, because every existing preserveAudit pin calls `engine.insert` directly. The ingress now skips runtime-owned field types outright and leaves them to the engine strip, which runs on every insert path including the direct `engine.insert` callers the ingress never sees. Author-declared `readonly` on every other type keeps its full #3043 width. The backing set moves to `@objectstack/spec/data` as `RUNTIME_OWNED_FIELD_TYPES` — the protocol's one statement of the ownership — now that a second consumer needs it; objectql keeps the reasoning comment and imports the membership. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY * chore(spec): finish the post-merge wholesale regen (docs references, api-surface) Recovery commit: completes the regen the dev agent never reached before the container restart. check:generated 10/10. * test(objectql): type the new runtime-owned ingress cases cleanly check:type-check-debt --re-measure caught +3 in objectql's TEST_DEBT layer (355 -> 358): the new cases copied the file's frozen-debt idioms — one-arg registerObject (packageId is required) and .at() under the package's lib target. Fixed to the file's clean two-arg idiom and indexed access; raw count back to 355, the 32-case suite stays green. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a643155 commit b3efeb7

7 files changed

Lines changed: 322 additions & 3 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-protocol": patch
4+
---
5+
6+
feat(spec): `Field.autonumber` declares the field `readonly: true` (#5628)
7+
8+
`FieldSchema.readonly` is a **two-part** contract: "never editable in forms"
9+
AND server-enforced on both write paths. #5503 closed the server half for
10+
`autonumber` **by type** — a caller-supplied record number is stripped before
11+
any driver sees it, flag or no flag. The form half is keyed on the **flag**, and
12+
`Field.autonumber` never set it. So an authoring/rendering layer that decides
13+
editability from `field.readonly` drew an editable "record number" input whose
14+
value the server was already guaranteed to discard: the user types one, the
15+
create succeeds, and the record comes back carrying the number the sequence
16+
issued instead. Data was never at risk (that half has been enforced since
17+
#5503/#5627); what was wrong is what the form told the user.
18+
19+
`Field.autonumber(...)` now emits `readonly: true`. The injection is applied
20+
**after** the author's config, so it cannot be spread away, and the authoring
21+
type rejects the one config that contradicts it — `Field.autonumber({ readonly:
22+
false })` is a **compile error** rather than a silently coerced value, because
23+
an "editable record number" is not a state the runtime can deliver. Restating
24+
`readonly: true` stays legal. A hand-written `{ type: 'autonumber' }` literal
25+
(YAML/JSON metadata, or a plain object in TS) is unchanged and unaffected: it is
26+
covered by the by-type server enforcement, which never depended on the flag.
27+
28+
Two consequences worth knowing:
29+
30+
- **A flow that writes an autonumber field is now caught at `os validate`.**
31+
`flow-update-readonly-field` reads the static flag, so an `update_record` node
32+
writing a builder-authored record number — already a silent no-op at run time
33+
— is now reported at design time instead of in server WARN logs.
34+
- **The historical-import exemption is unchanged**, and stays that way by
35+
construction. The DataProtocol create ingress (`stripReadonlyForInsert`,
36+
#3043) knows only the `isSystem` exemption, while the engine's runtime-owned
37+
strip also honours `preserveAudit` (#3493 — a migration reinstating legacy
38+
record numbers). Now that the field carries the flag, the ingress would have
39+
deleted that value *before* the engine could keep it, so the ingress skips
40+
runtime-owned field types outright and leaves them to the engine strip, which
41+
runs on every insert path (including the direct `engine.insert` callers the
42+
ingress never sees). Author-declared `readonly` on every other field type is
43+
stripped at the ingress exactly as wide as before.
44+
45+
The set backing "which types the runtime owns" is now declared once in the
46+
protocol — `RUNTIME_OWNED_FIELD_TYPES`, exported from `@objectstack/spec/data`
47+
— and read by both consumers (objectql's write-path strips, the DataProtocol
48+
ingress) instead of each carrying its own literal.

packages/metadata-protocol/src/protocol.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
parseFilterAST, isFilterAST, VALID_AST_OPERATORS, REFERENCE_VALUE_TYPES, referenceTargetOf,
2929
AggregationFunction, DateGranularity, resolveSearchFieldResolution,
3030
SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS,
31+
RUNTIME_OWNED_FIELD_TYPES,
3132
RPC_QUERY_ALIAS_SLOTS, foldQueryAliasSlots,
3233
type QueryAliasConflict, type QueryAliasSlot,
3334
type DroppedFieldsEvent, type QueryAST, type EngineQueryOptionsParsed,
@@ -1026,6 +1027,20 @@ const CLONE_STRIP_FIELDS: readonly string[] = [
10261027
* reject. The #3043 threat is app approval/status/verdict fields (the issue's
10271028
* `sporadic_application` / `assessment`), never `sys_`; this is the same
10281029
* platform-vs-authored boundary `applySystemFields` uses for ownership.
1030+
*
1031+
* SCOPE, second boundary — RUNTIME-OWNED field types
1032+
* ({@link RUNTIME_OWNED_FIELD_TYPES}: today `autonumber`) are left to the
1033+
* ENGINE's own insert strip (`stripRuntimeOwnedFields`, #5503), which runs on
1034+
* every insert path including the direct `engine.insert` callers this ingress
1035+
* never sees. Skipping them here removes no protection and prevents this seam
1036+
* from PRE-EMPTING an exemption it does not implement: the engine strip honours
1037+
* `preserveAudit` (#3493 — a historical import reinstating legacy record
1038+
* numbers) while this one knows only `isSystem`. Before #5628 the distinction
1039+
* was academic, because an `autonumber` field carried no `readonly` flag for the
1040+
* loop below to notice; now that `Field.autonumber` injects one, stripping here
1041+
* would silently delete the value a historical import is entitled to keep,
1042+
* BEFORE the engine could apply the whitelist. Author-declared `readonly` on
1043+
* every other type is untouched — the #3043 strip is exactly as wide as it was.
10291044
*/
10301045
function stripReadonlyForInsert(schema: any, data: any, context: any): any {
10311046
if (context?.isSystem) return data;
@@ -1037,6 +1052,9 @@ function stripReadonlyForInsert(schema: any, data: any, context: any): any {
10371052
let out = row;
10381053
for (const name of Object.keys(fields)) {
10391054
if (!fields[name]?.readonly) continue;
1055+
// [#5628] The engine's runtime-owned strip owns these, with the
1056+
// wider exemption set. See the note above.
1057+
if (RUNTIME_OWNED_FIELD_TYPES.has(String(fields[name]?.type ?? ''))) continue;
10401058
if (!(name in out)) continue;
10411059
if (out === row) out = { ...row };
10421060
delete out[name];

packages/objectql/src/engine-autonumber-runtime-owned.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -537,3 +537,113 @@ describe('#5503 — autonumber is runtime-owned: UPDATE', () => {
537537
expect(res.account_number).toBe('HOOK-0002');
538538
});
539539
});
540+
541+
/**
542+
* #5628 — the FLAGGED autonumber field: `Field.autonumber` now injects
543+
* `readonly: true` so the form half of the `readonly` contract ("never editable
544+
* in forms") holds for a record number the server was already guaranteed to
545+
* discard. Every case above uses an UNflagged `type: 'autonumber'`, so none of
546+
* them sees what that flag changes on the way in.
547+
*
548+
* What it changes is WHICH strip gets to the field first. A `readonly` field is
549+
* also stripped at the DataProtocol create INGRESS (`stripReadonlyForInsert`,
550+
* #3043) — a seam that knows only the `isSystem` exemption, while the engine's
551+
* runtime-owned strip also honours `preserveAudit` (#3493: a historical import
552+
* reinstating legacy record numbers). Left alone, the flag would therefore have
553+
* SILENTLY narrowed a documented exemption: the ingress would delete the legacy
554+
* number before the engine could keep it, with no test anywhere going red,
555+
* because every existing preserveAudit pin calls `engine.insert` directly.
556+
*
557+
* So the ingress skips runtime-owned types outright (they are covered by the
558+
* engine strip on EVERY insert path, including the direct `engine.insert`
559+
* callers the ingress never sees) and these cases pin both halves of that: the
560+
* ordinary caller is still stripped, and the historical import still keeps its
561+
* value — flagged or not, the verdicts are identical.
562+
*/
563+
describe('#5628 — a `readonly: true` autonumber keeps the #5503 exemption set', () => {
564+
// What `Field.autonumber({ label: 'Invoice No.' })` produces since #5628.
565+
const INVOICE = {
566+
name: 'an_invoice',
567+
label: 'Invoice',
568+
fields: {
569+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
570+
name: { name: 'name', label: 'Name', type: 'text' as const },
571+
invoice_number: {
572+
name: 'invoice_number',
573+
label: 'Invoice No.',
574+
type: 'autonumber' as const,
575+
readonly: true,
576+
autonumberFormat: 'INV-{0000}',
577+
},
578+
},
579+
};
580+
581+
let rig: Awaited<ReturnType<typeof makeEngine>>;
582+
beforeEach(async () => {
583+
rig = await makeEngine();
584+
rig.engine.registry.registerObject(INVOICE as any, 'test');
585+
});
586+
587+
it('still strips an ordinary caller-supplied number and issues the sequence value', async () => {
588+
const created = await rig.protocol.createData({
589+
object: 'an_invoice',
590+
data: { name: 'forge', invoice_number: 'INV-9999' },
591+
});
592+
expect(created.record.invoice_number).toBe('INV-0001');
593+
expect(rig.createdRows[rig.createdRows.length - 1]?.invoice_number).toBe('INV-0001');
594+
});
595+
596+
it('still REPORTS the strip to the caller (#3407 / #3431)', async () => {
597+
const created = await rig.protocol.createData({
598+
object: 'an_invoice',
599+
data: { name: 'forge', invoice_number: 'INV-9999' },
600+
});
601+
const dropped = (created as { droppedFields?: DroppedFieldsEvent[] }).droppedFields ?? [];
602+
expect(dropped.flatMap((e) => e.fields)).toContain('invoice_number');
603+
});
604+
605+
it('keeps a legacy number for a `preserveAudit` historical import THROUGH THE INGRESS (#3493)', async () => {
606+
// The regression this whole describe exists for: the ingress strip has no
607+
// `preserveAudit` exemption, so if it acted on the flag the value would be
608+
// gone before the engine's whitelist ran.
609+
const created = await rig.protocol.createData({
610+
object: 'an_invoice',
611+
data: { name: 'legacy', invoice_number: 'LEGACY-0007' },
612+
context: { preserveAudit: true },
613+
});
614+
expect(created.record.invoice_number).toBe('LEGACY-0007');
615+
});
616+
617+
it('keeps an explicit number for a system write through the ingress', async () => {
618+
const created = await rig.protocol.createData({
619+
object: 'an_invoice',
620+
data: { name: 'seeded', invoice_number: 'INV-000042' },
621+
context: { isSystem: true },
622+
});
623+
expect(created.record.invoice_number).toBe('INV-000042');
624+
});
625+
626+
it('an author-declared `readonly` field of an ORDINARY type is still stripped at the ingress', async () => {
627+
// The #3043 strip keeps its full width — only runtime-owned types moved.
628+
rig.engine.registry.registerObject({
629+
name: 'an_case',
630+
label: 'Case',
631+
fields: {
632+
id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true },
633+
title: { name: 'title', label: 'Title', type: 'text' as const },
634+
approval_status: {
635+
name: 'approval_status',
636+
label: 'Approval',
637+
type: 'text' as const,
638+
readonly: true,
639+
defaultValue: 'draft',
640+
},
641+
},
642+
} as any, 'test');
643+
const created = await rig.protocol.createData({
644+
object: 'an_case',
645+
data: { title: 'forged', approval_status: 'approved' },
646+
});
647+
expect(created.record.approval_status).toBe('draft');
648+
});
649+
});

packages/objectql/src/validation/rule-validator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@
190190

191191
import { ExpressionEngine, collectCelRootIdentifiers } from '@objectstack/formula';
192192
import type { Expression } from '@objectstack/spec';
193-
import { AUDIT_PROVENANCE_FIELDS } from '@objectstack/spec/data';
193+
import { AUDIT_PROVENANCE_FIELDS, RUNTIME_OWNED_FIELD_TYPES } from '@objectstack/spec/data';
194194
import Ajv, { type ValidateFunction } from 'ajv';
195195
// #5029 — `format` is NOT built into ajv 8; it ships in this separate package.
196196
// See the `const ajv` note below for why the runtime registers it.
@@ -821,8 +821,15 @@ export function stripReadonlyWhenFieldsMulti(
821821
*
822822
* Keep this set to types whose value is (a) persisted, (b) issued by the
823823
* runtime, and (c) never legitimately supplied by a caller.
824+
*
825+
* The set itself now lives in `@objectstack/spec` (`RUNTIME_OWNED_FIELD_TYPES`,
826+
* #5628) — the protocol's one statement of the ownership — because a SECOND
827+
* consumer needs it: the DataProtocol create ingress, whose `readonly` strip
828+
* carries a NARROWER exemption set than this module's (no `preserveAudit`), and
829+
* which therefore has to recognise these types to stay out of their way. A
830+
* literal copied over there is the drift `AUDIT_TIMELINE_FIELDS` below stopped
831+
* paying for. This module keeps the reasoning; the membership is imported.
824832
*/
825-
const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet<string> = new Set(['autonumber']);
826833

827834
/**
828835
* Whether the runtime owns this field's value outright — i.e. the field is

packages/spec/api-surface/data.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@
433433
"REFERENCE_VALUE_TYPES (const)",
434434
"RETIRED_FILTER_OPERATORS (const)",
435435
"RPC_QUERY_ALIAS_SLOTS (const)",
436+
"RUNTIME_OWNED_FIELD_TYPES (const)",
436437
"RangeOperatorSchema (const)",
437438
"RecordFlow (type)",
438439
"RecordFlowContainer (type)",
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5628 — `Field.autonumber` declares the field `readonly: true`.
5+
*
6+
* `FieldSchema.readonly` is a TWO-part contract: "never editable in forms" AND
7+
* server-enforced on both write paths. #5503 / PR #5627 closed the server half
8+
* for `autonumber` BY TYPE ({@link RUNTIME_OWNED_FIELD_TYPES}) — a
9+
* caller-supplied record number is stripped before any driver sees it, flag or
10+
* no flag. The FORM half is keyed on the FLAG, and the builder did not set it,
11+
* so an authoring/rendering layer deciding editability from `field.readonly`
12+
* drew an editable "record number" input whose value the server was already
13+
* guaranteed to discard: the user types one, the create succeeds, and the
14+
* record comes back carrying the number the sequence issued instead (#4632's
15+
* "second-class" shape — the write path reports the drop in `droppedFields`,
16+
* but a renderer need not surface that).
17+
*
18+
* These cases pin the flag on the builder's output, and the AUTHORING-TIME
19+
* verdict on the one config that contradicts it. `readonly: false` on an
20+
* autonumber field is not a preference the runtime can honour — the value is
21+
* issued by the engine or the driver's persistent sequence, so an "editable
22+
* record number" cannot exist. It is rejected at the authoring site by `tsc`
23+
* (a loud compile error where the metadata is written) rather than accepted and
24+
* silently coerced, which is the lenient-consumer shape ADR-aligned authoring
25+
* exists to avoid.
26+
*/
27+
28+
import { describe, it, expect } from 'vitest';
29+
import { Field, FieldSchema, RUNTIME_OWNED_FIELD_TYPES } from './field.zod';
30+
31+
describe('#5628 — Field.autonumber injects readonly: true', () => {
32+
it('declares the field read-only', () => {
33+
const f = Field.autonumber({ label: 'Auto Number' });
34+
expect(f.type).toBe('autonumber');
35+
expect(f.readonly).toBe(true);
36+
});
37+
38+
it('keeps every other config key the author wrote', () => {
39+
const f = Field.autonumber({ label: 'Invoice No.', autonumberFormat: 'INV-{0000}', required: true });
40+
expect(f).toMatchObject({
41+
type: 'autonumber',
42+
label: 'Invoice No.',
43+
autonumberFormat: 'INV-{0000}',
44+
required: true,
45+
readonly: true,
46+
});
47+
});
48+
49+
it('sets the flag with no config at all', () => {
50+
expect(Field.autonumber().readonly).toBe(true);
51+
});
52+
53+
it('restating `readonly: true` is allowed and changes nothing', () => {
54+
expect(Field.autonumber({ readonly: true, label: 'No.' }).readonly).toBe(true);
55+
});
56+
57+
it('rejects `readonly: false` at the authoring site (compile error), not at runtime', () => {
58+
// The pin: this line must NOT compile. `tsconfig.test.json` puts this file
59+
// in front of `tsc` (#5286), so the directive is a real check — delete the
60+
// injection's type narrowing and `pnpm --filter @objectstack/spec typecheck`
61+
// fails on an unused @ts-expect-error.
62+
// @ts-expect-error an autonumber field is runtime-owned: `readonly` is always true
63+
const f = Field.autonumber({ readonly: false });
64+
// And a caller that reaches the builder from untyped JS (or through a cast)
65+
// still gets the flag: the injection is applied AFTER `config`, so it cannot
66+
// be spread away. Silent coercion is acceptable ONLY because the authoring
67+
// surface above rejects the same input loudly.
68+
expect(f.readonly).toBe(true);
69+
});
70+
71+
it('the builder output parses as a valid field (the flag is a real authoring key)', () => {
72+
const parsed = FieldSchema.safeParse({
73+
name: 'invoice_number',
74+
label: 'Invoice No.',
75+
...Field.autonumber({ autonumberFormat: 'INV-{0000}' }),
76+
});
77+
expect(parsed.success).toBe(true);
78+
expect(parsed.success && parsed.data.readonly).toBe(true);
79+
});
80+
});
81+
82+
describe('#5628 / #5503 — RUNTIME_OWNED_FIELD_TYPES is the protocol vocabulary', () => {
83+
it('names `autonumber`', () => {
84+
expect(RUNTIME_OWNED_FIELD_TYPES.has('autonumber')).toBe(true);
85+
});
86+
87+
it('does NOT name the other calculated types, nor ordinary ones', () => {
88+
// `formula` is computed on read (no stored caller value to strip);
89+
// `summary` is a stored roll-up a caller MAY legitimately seed (#6014).
90+
for (const t of ['formula', 'summary', 'text', 'number']) {
91+
expect(RUNTIME_OWNED_FIELD_TYPES.has(t)).toBe(false);
92+
}
93+
});
94+
});

packages/spec/src/data/field.zod.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ export const FieldType = z.enum([
7878

7979
export type FieldType = z.input<typeof FieldType>;
8080

81+
/**
82+
* Field types whose stored value the RUNTIME owns outright — issued by the
83+
* engine (or the driver's persistent sequence), never supplied by a caller on
84+
* either write path. Today exactly `autonumber` (#5503).
85+
*
86+
* This is the PROTOCOL's statement of that ownership, so the consumers that act
87+
* on it read one vocabulary instead of each carrying its own literal: objectql's
88+
* write-path strips (`isRuntimeOwnedField` / `stripRuntimeOwnedFields`, which
89+
* treat these types as implicitly read-only), and the DataProtocol create
90+
* ingress, which defers to those strips rather than pre-empting them with its
91+
* own narrower exemption set (`stripReadonlyForInsert`, #5628).
92+
*
93+
* Keep the set to types whose value is (a) persisted, (b) issued by the runtime,
94+
* and (c) never legitimately supplied by a caller. `formula` and `summary` are
95+
* deliberately NOT here: they are derived-on-read/roll-up, not stored values a
96+
* caller could forge into a sequence.
97+
*/
98+
export const RUNTIME_OWNED_FIELD_TYPES: ReadonlySet<string> = new Set<string>(['autonumber']);
99+
81100
/**
82101
* Select Option Schema
83102
*
@@ -910,7 +929,29 @@ export const Field = {
910929
avatar: (config: FieldInput = {}) => ({ type: 'avatar', ...config } as const),
911930
formula: (config: FieldInput = {}) => ({ type: 'formula', ...config } as const),
912931
summary: (config: FieldInput = {}) => ({ type: 'summary', ...config } as const),
913-
autonumber: (config: FieldInput = {}) => ({ type: 'autonumber', ...config } as const),
932+
/**
933+
* Auto-number — a record number the RUNTIME issues from its sequence.
934+
*
935+
* The builder injects `readonly: true` (#5628). `readonly` is a TWO-part
936+
* contract (see `FieldSchema.readonly`): "never editable in forms" AND
937+
* server-enforced on both write paths. #5503 closed the server half for
938+
* `autonumber` by type ({@link RUNTIME_OWNED_FIELD_TYPES}), but the FORM half
939+
* is keyed on the flag — so without it a renderer drew an editable "record
940+
* number" box whose value the server was already guaranteed to discard: the
941+
* user types a number, the create succeeds, and the record comes back
942+
* carrying a different one. Declaring the flag the builder's output already
943+
* behaves like is the shortest "declared = enforced" path.
944+
*
945+
* The injection is UNCONDITIONAL — it is applied after `config`, so it cannot
946+
* be spread away — and `readonly: false` is a compile error at the authoring
947+
* site rather than a silent coercion: an autonumber field is runtime-owned by
948+
* construction, so "editable record number" is not a state the author can
949+
* ask for. Restating `readonly: true` is allowed (it is merely redundant).
950+
* A hand-written `{ type: 'autonumber' }` literal is unaffected — it is
951+
* covered by the by-TYPE server enforcement, which never depended on the flag.
952+
*/
953+
autonumber: (config: FieldInput & { readonly?: true } = {}) =>
954+
({ type: 'autonumber', ...config, readonly: true } as const),
914955
markdown: (config: FieldInput = {}) => ({ type: 'markdown', ...config } as const),
915956
html: (config: FieldInput = {}) => ({ type: 'html', ...config } as const),
916957
password: (config: FieldInput = {}) => ({ type: 'password', ...config } as const),

0 commit comments

Comments
 (0)