Skip to content

Commit e3eed8d

Browse files
dmealingclaude
andcommitted
feat(#208): TS loader validation for @sql/@Unmanaged (6 fail-closed rules)
Task 3 — validate-source-escapes.ts (sibling of validate-source-roles.ts), wired into the load pipeline. The six rules from design §5: - R1 @SQL AND @Unmanaged on one source → ERR_SQL_BODY_WITH_UNMANAGED - R2 @SQL on a writable @kind (table) → ERR_SQL_BODY_ON_WRITABLE_KIND - R3 @SQL present but empty/whitespace → ERR_BAD_ATTR_VALUE (reads the RAW attr, not the sqlBody accessor which already narrows empty→undefined) - R4 origin.*-bearing field under an @SQL host → ERR_ORIGIN_UNDER_SQL_BODY - R5 object.projection @filter (#207) + @SQL host → ERR_ORIGIN_UNDER_SQL_BODY - R6 origin.*-bearing field under an @Unmanaged host → WARN_ORIGIN_UNDER_UNMANAGED The R4-error / R6-warn asymmetry is deliberate (§5.6): @SQL is a second body (two sources of truth); @Unmanaged acts on nothing, so a documented-but- unacted-on lineage is benign. ADR-0039 own/resolving discipline applied. Three ERR codes + one WARN code added to errors.ts + the TS-owned fixtures/conformance/ERROR-CODES.json manifest (the other ports do not gate on it — Python conformance green with the change). TS-only per the plan; the 4-port validation mirror lands in Tasks 7–9. Gated by validate-source-escapes.test.ts (one case per rule); full metadata suite 2195/2195 + typecheck clean; the Task-1 positive source-sql-escape fixture still passes (its shapes are legal). Deep review folded into the post-Task-6 TS-reference checkpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent 81c10df commit e3eed8d

5 files changed

Lines changed: 319 additions & 1 deletion

File tree

fixtures/conformance/ERROR-CODES.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@
6969
"ERR_INVALID_METAMODEL_CONSTRAINT": "FR-033: a provider set's merged metamodel constraint graph contains a contradiction surfaced by validateConstraints — one of: (1) a parents/children rule references an unregistered type.subType (dangling); (2) a required child (min>=1) whose type.subType is not admitted under that parent (unsatisfiable); (3) bad cardinality (min>max, max:0 with min>=1, or min<0); (4) closed-set clash — a child declares a parent whose OWN children is a closed set (no * wildcard) that does not admit it; (5) a required-child cycle that cannot bottom out; (6) the same attr name contributed twice (across providers or the extends chain) with a conflicting valueType/required/default. The detail names which check and the offending type(s).",
7070
"ERR_INVALID_INDEX": "An index.lookup's @fields is empty (at least one field is required) or names a field that does not exist on the owning entity's effective (resolved, via extends) field set.",
7171
"ERR_COMPUTED_TYPE_MISMATCH": "#195: an origin.computed @expr tree's inferred root type does not equal the carrying field's declared field.<subType>. A computed column's type is DERIVED from its expression, never asserted (no @convert escape), so a mismatch is a hard load error (sibling of ERR_PASSTHROUGH_TYPE_MISMATCH).",
72-
"ERR_UNKNOWN_EXPR_NODE": "#195: an origin.computed @expr tree contains a node whose kind/op/fn is not in the closed expression grammar (field/value refs, comparisons sharing the filter op vocabulary, isNull/isNotNull, and/or/not, coalesce). Fail-closed per ADR-0023; the detail names the offending token."
72+
"ERR_UNKNOWN_EXPR_NODE": "#195: an origin.computed @expr tree contains a node whose kind/op/fn is not in the closed expression grammar (field/value refs, comparisons sharing the filter op vocabulary, isNull/isNotNull, and/or/not, coalesce). Fail-closed per ADR-0023; the detail names the offending token.",
73+
"ERR_SQL_BODY_WITH_UNMANAGED": "#208: a source.rdb declares both @sql (author-supplied body) and @unmanaged (DDL owned elsewhere). The two markers are the mutually exclusive non-default states of one DDL-ownership axis — contradictory on a single source.",
74+
"ERR_SQL_BODY_ON_WRITABLE_KIND": "#208: a source.rdb declares @sql with a writable @kind (\"table\", the default). A hand-written CREATE TABLE would bypass the column-diff machinery; tables are fully modeled or @unmanaged, never opaque-bodied.",
75+
"ERR_ORIGIN_UNDER_SQL_BODY": "#208: a host object whose read source carries @sql also declares an origin.*-bearing (derived) field, or (on object.projection) an @filter (#207) — two sources of truth for the same body (the synthesized derivation/outer-WHERE vs. the author's verbatim SQL). Fail-closed."
7376
}
7477
}

server/typescript/packages/metadata/src/errors.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ export const ERROR_CODES = [
178178
// #195 — origin.computed @expr contains a node whose kind/op/fn is not in the
179179
// closed expression grammar (ADR-0023 fail-closed). Detail names the bad token.
180180
"ERR_UNKNOWN_EXPR_NODE",
181+
// #208 — a source.rdb declares BOTH @sql (author-supplied body) and
182+
// @unmanaged (DDL owned elsewhere). The two markers are the mutually
183+
// exclusive non-default states of one "who owns this DDL" axis.
184+
"ERR_SQL_BODY_WITH_UNMANAGED",
185+
// #208 — a source.rdb declares @sql with a writable @kind ("table", the
186+
// default). A hand-written CREATE TABLE would bypass the column-diff
187+
// machinery; tables are fully modeled or @unmanaged, never opaque-bodied.
188+
"ERR_SQL_BODY_ON_WRITABLE_KIND",
189+
// #208 — a host object whose read source carries @sql also declares an
190+
// origin.*-bearing (derived) field, or (on object.projection) an @filter —
191+
// two sources of truth for the same body (the synthesized derivation/WHERE
192+
// vs. the author's verbatim SQL). Fail-closed.
193+
"ERR_ORIGIN_UNDER_SQL_BODY",
181194
"ERR_UNKNOWN",
182195
] as const;
183196

@@ -197,6 +210,13 @@ export const WARNING_CODES = [
197210
// implication does not apply to value-objects; the attr is retained for
198211
// language-specific record/struct treatment (e.g. Kotlin `val` vs `var`).
199212
"WARN_READONLY_VALUE_OBJECT",
213+
// #208 — a host object whose source carries @unmanaged also declares an
214+
// origin.*-bearing (derived) field. Deliberate asymmetry with
215+
// ERR_ORIGIN_UNDER_SQL_BODY: @unmanaged acts on nothing (the tool never
216+
// touches the object's DDL), so a documented-but-unacted-on lineage is
217+
// benign — a WARN preserves the matview symmetry and lets an author
218+
// document lineage for future in-process evaluation (#211).
219+
"WARN_ORIGIN_UNDER_UNMANAGED",
200220
] as const;
201221
export type WarningCode = (typeof WARNING_CODES)[number];
202222

server/typescript/packages/metadata/src/loader/meta-data-loader.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { parseJson } from "../parser-json.js";
2121
import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateIndexLookupFields, validateProjectionFilter } from "./validation-passes.js";
2222
import { runRegisteredValidation } from "./validation-registry.js";
2323
import { validateSourceRoles } from "../persistence/source/validate-source-roles.js";
24+
import { validateSourceEscapes } from "../persistence/source/validate-source-escapes.js";
2425
import { validateSourcePhysicalNames } from "../persistence/source/validate-source-physical-names.js";
2526
import { validateSourceParameterRef } from "../persistence/source/validate-source-parameter-ref.js";
2627
import { validateFieldReadOnly } from "../core/field/validate-field-readonly.js";
@@ -635,6 +636,13 @@ export class MetaDataLoader {
635636
// ERR_SOURCE_MULTIPLE_PRIMARY).
636637
errors.push(...validateSourceRoles(root));
637638

639+
// #208 — DDL-ownership escape valves: source.rdb @sql / @unmanaged
640+
// fail-closed rules (ERR_SQL_BODY_WITH_UNMANAGED / ERR_SQL_BODY_ON_WRITABLE_KIND /
641+
// ERR_BAD_ATTR_VALUE / ERR_ORIGIN_UNDER_SQL_BODY / WARN_ORIGIN_UNDER_UNMANAGED).
642+
const sourceEscapeResult = validateSourceEscapes(root);
643+
errors.push(...sourceEscapeResult.errors);
644+
envelopeWarnings.push(...sourceEscapeResult.warnings);
645+
638646
// FR-016 / ADR-0018 — per-kind physical-name alias validation on
639647
// source.rdb (kind-matching alias, no multiples, legacy @table warning).
640648
const physicalNameResult = validateSourcePhysicalNames(root);
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Validation pass: DDL-ownership escape valves (#208) — @sql / @unmanaged on
2+
// source.rdb. Sibling of validate-source-roles.ts / validate-source-physical-names.ts.
3+
//
4+
// #208 gives source.rdb two mutually exclusive, non-default DDL-ownership
5+
// markers: @sql (a hand-written body the tool registers/fingerprints/drift-
6+
// checks but never authors) and @unmanaged (this object's DDL is owned
7+
// elsewhere — the tool never touches it). This pass enforces the six
8+
// fail-closed rules from the design doc (§5):
9+
//
10+
// R1 @sql AND @unmanaged on the SAME source → ERR_SQL_BODY_WITH_UNMANAGED
11+
// R2 @sql on a writable @kind ("table", the default) → ERR_SQL_BODY_ON_WRITABLE_KIND
12+
// R3 @sql present but empty / whitespace-only → ERR_BAD_ATTR_VALUE
13+
// R4 origin.*-bearing field under an @sql host → ERR_ORIGIN_UNDER_SQL_BODY
14+
// R5 object.projection @filter (#207) + @sql host → ERR_ORIGIN_UNDER_SQL_BODY
15+
// R6 origin.*-bearing field under an @unmanaged host → WARN_ORIGIN_UNDER_UNMANAGED
16+
//
17+
// R1–R3 are per-source (declaration-layer). R4–R6 are per-host-object: a host
18+
// with ANY own source.rdb carrying @sql/@unmanaged is judged against its own
19+
// fields (and, for R5, its own @filter). The asymmetry between R4 (hard error)
20+
// and R6 (warn) is deliberate (design doc §5.6): @sql is a second body (two
21+
// sources of truth for the SAME data); @unmanaged acts on nothing, so a
22+
// documented-but-unacted-on lineage is benign.
23+
24+
import type { MetaData } from "../../shared/meta-data.js";
25+
import { ParseError } from "../../errors.js";
26+
import type { LoaderWarning } from "../../source.js";
27+
import { TYPE_OBJECT, TYPE_SOURCE, TYPE_FIELD } from "../../shared/base-types.js";
28+
import { OBJECT_SUBTYPE_PROJECTION, OBJECT_PROJECTION_ATTR_FILTER } from "../../core/object/object-constants.js";
29+
import { MetaSource } from "./meta-source.js";
30+
import { MetaField } from "../../core/field/meta-field.js";
31+
import { SOURCE_ATTR_SQL, SOURCE_SUBTYPE_RDB } from "./source-constants.js";
32+
33+
export interface SourceEscapeValidationResult {
34+
errors: ParseError[];
35+
warnings: LoaderWarning[];
36+
}
37+
38+
export function validateSourceEscapes(root: MetaData): SourceEscapeValidationResult {
39+
const errors: ParseError[] = [];
40+
const warnings: LoaderWarning[] = [];
41+
42+
// ADR-0039: root has no super; children()==ownChildren() but resolving is the default.
43+
for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) {
44+
// ADR-0039: own — declaration-layer source iteration (mirrors validateSourceRoles /
45+
// validateSourceParameterRef): R1–R3 judge markers DECLARED on this object's own sources.
46+
const sources = obj
47+
.ownChildren()
48+
.filter(
49+
(c): c is MetaSource =>
50+
c.type === TYPE_SOURCE && c.subType === SOURCE_SUBTYPE_RDB && c instanceof MetaSource,
51+
);
52+
53+
let hasSqlHost = false;
54+
let hasUnmanagedHost = false;
55+
56+
for (const source of sources) {
57+
// ADR-0039: resolving — @sql/@unmanaged are inheritable (follow the
58+
// @role/effectiveKind precedent — sources are inheritable, NOT the
59+
// @dbColumnType own-only exception).
60+
const sqlSet = source.sqlBody !== undefined;
61+
const unmanagedSet = source.isUnmanaged;
62+
63+
// R1 — contradictory DDL owners on the same source.
64+
if (sqlSet && unmanagedSet) {
65+
errors.push(
66+
new ParseError(
67+
`source.rdb on object "${obj.name}" declares both @sql and @unmanaged — these are the ` +
68+
`mutually exclusive non-default states of one DDL-ownership axis (an author-supplied body ` +
69+
`contradicts "someone else owns this DDL")`,
70+
{ code: "ERR_SQL_BODY_WITH_UNMANAGED", source: source.source },
71+
),
72+
);
73+
}
74+
75+
// R2 — @sql on a writable kind would bypass the column-diff machinery;
76+
// tables are fully modeled or @unmanaged, never opaque-bodied.
77+
if (sqlSet && source.isWritable()) {
78+
errors.push(
79+
new ParseError(
80+
`source.rdb on object "${obj.name}" declares @sql with a writable @kind ("${source.effectiveKind}") — ` +
81+
`@sql is legal only on a read-only kind (view/materializedView/storedProc/tableFunction); ` +
82+
`a writable table is either fully modeled or marked @unmanaged, never opaque-bodied`,
83+
{ code: "ERR_SQL_BODY_ON_WRITABLE_KIND", source: source.source },
84+
),
85+
);
86+
}
87+
88+
// R3 — @sql present but empty/whitespace. MUST read the raw attr, not
89+
// the sqlBody accessor: sqlBody already narrows an empty string to
90+
// undefined, which would make a present-but-empty @sql indistinguishable
91+
// from an absent one.
92+
const rawSql = source.attr(SOURCE_ATTR_SQL);
93+
if (rawSql !== undefined && (typeof rawSql !== "string" || rawSql.trim() === "")) {
94+
errors.push(
95+
new ParseError(
96+
`source.rdb on object "${obj.name}" sets @sql to an empty/whitespace-only value; ` +
97+
`@sql requires a non-empty SQL body`,
98+
{ code: "ERR_BAD_ATTR_VALUE", source: source.source },
99+
),
100+
);
101+
}
102+
103+
if (sqlSet) hasSqlHost = true;
104+
if (unmanagedSet) hasUnmanagedHost = true;
105+
}
106+
107+
if (!hasSqlHost && !hasUnmanagedHost) continue;
108+
109+
// R4 / R6 — origin.*-bearing (derived) own fields under an @sql / @unmanaged
110+
// host. ADR-0039: own — origin.* never inherits (ADR-0029), so isDerived()
111+
// is own-only by policy (mirrors validateDerivedFieldProvidability).
112+
// @sql (hard error) takes priority over @unmanaged (warn) when a host
113+
// happens to declare both markers across different sources.
114+
for (const field of obj.ownChildren().filter((c): c is MetaField => c.type === TYPE_FIELD && c instanceof MetaField)) {
115+
if (!field.isDerived()) continue;
116+
if (hasSqlHost) {
117+
errors.push(
118+
new ParseError(
119+
`field "${obj.name}.${field.name}" carries an origin.* (derived) child, but "${obj.name}" has a ` +
120+
`read source carrying @sql — the synthesized derivation and the author's verbatim SQL are two ` +
121+
`sources of truth for the same body`,
122+
{ code: "ERR_ORIGIN_UNDER_SQL_BODY", source: field.source },
123+
),
124+
);
125+
} else {
126+
warnings.push({
127+
code: "WARN_ORIGIN_UNDER_UNMANAGED",
128+
message:
129+
`field "${obj.name}.${field.name}" carries an origin.* (derived) child, but "${obj.name}" has a ` +
130+
`source marked @unmanaged — the tool never touches this object's DDL, so the derivation is ` +
131+
`documented lineage only (not acted on); this is informational, not an error`,
132+
source: field.source,
133+
});
134+
}
135+
}
136+
137+
// R5 — a projection's row-scope @filter (#207) lowers to the outer WHERE
138+
// of a TOOL-SYNTHESIZED body; with @sql the author owns the body (and its
139+
// WHERE), so wrapping it is deferred cleverness (design doc D5) — reject.
140+
if (hasSqlHost && obj.subType === OBJECT_SUBTYPE_PROJECTION) {
141+
// ADR-0039: own — the @filter is declared locally on this projection
142+
// (mirrors validateProjectionFilter).
143+
const filter = obj.ownAttr(OBJECT_PROJECTION_ATTR_FILTER);
144+
if (filter !== undefined) {
145+
errors.push(
146+
new ParseError(
147+
`projection "${obj.name}" declares both @filter and an @sql read source — a view-level @filter ` +
148+
`lowers to the outer WHERE of a synthesized body; with @sql the author owns the body (and its ` +
149+
`WHERE), so the two cannot be combined`,
150+
{ code: "ERR_ORIGIN_UNDER_SQL_BODY", source: obj.source },
151+
),
152+
);
153+
}
154+
}
155+
}
156+
157+
return { errors, warnings };
158+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// #208 — DDL-ownership escape valves loader validation (§5 R1–R6).
2+
// One test per rule: R1–R5 are hard errors, R6 is a WARN (not an error).
3+
4+
import { describe, expect, test } from "bun:test";
5+
import { MetaDataLoader } from "../src/loader/meta-data-loader.js";
6+
import { InMemoryStringSource } from "../src/loader/meta-data-source.js";
7+
8+
async function loadDoc(doc: unknown) {
9+
return new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]);
10+
}
11+
const codesOf = (errors: readonly Error[]) => errors.map((e) => (e as { code?: string }).code);
12+
13+
describe("#208 source escape validation (@sql / @unmanaged)", () => {
14+
test("R1: @sql AND @unmanaged on one source → ERR_SQL_BODY_WITH_UNMANAGED", async () => {
15+
const { errors } = await loadDoc({
16+
"metadata.root": { package: "acme", children: [
17+
{ "object.projection": { name: "H", children: [
18+
{ "source.rdb": { "@kind": "view", "@view": "v", "@sql": "SELECT 1", "@unmanaged": true } },
19+
{ "field.long": { name: "id" } },
20+
] } },
21+
] },
22+
});
23+
expect(codesOf(errors)).toContain("ERR_SQL_BODY_WITH_UNMANAGED");
24+
});
25+
26+
test("R2: @sql on @kind:table (writable, default) → ERR_SQL_BODY_ON_WRITABLE_KIND", async () => {
27+
const { errors } = await loadDoc({
28+
"metadata.root": { package: "acme", children: [
29+
{ "object.entity": { name: "H", children: [
30+
{ "source.rdb": { "@table": "t", "@sql": "SELECT 1" } },
31+
{ "field.long": { name: "id" } },
32+
{ "identity.primary": { name: "id", "@fields": ["id"] } },
33+
] } },
34+
] },
35+
});
36+
expect(codesOf(errors)).toContain("ERR_SQL_BODY_ON_WRITABLE_KIND");
37+
});
38+
39+
test("R3: @sql present but empty string → ERR_BAD_ATTR_VALUE", async () => {
40+
const { errors } = await loadDoc({
41+
"metadata.root": { package: "acme", children: [
42+
{ "object.projection": { name: "H", children: [
43+
{ "source.rdb": { "@kind": "view", "@view": "v", "@sql": "" } },
44+
{ "field.long": { name: "id" } },
45+
] } },
46+
] },
47+
});
48+
expect(codesOf(errors)).toContain("ERR_BAD_ATTR_VALUE");
49+
});
50+
51+
test("R3b: @sql present but whitespace-only → ERR_BAD_ATTR_VALUE", async () => {
52+
const { errors } = await loadDoc({
53+
"metadata.root": { package: "acme", children: [
54+
{ "object.projection": { name: "H", children: [
55+
{ "source.rdb": { "@kind": "view", "@view": "v", "@sql": " " } },
56+
{ "field.long": { name: "id" } },
57+
] } },
58+
] },
59+
});
60+
expect(codesOf(errors)).toContain("ERR_BAD_ATTR_VALUE");
61+
});
62+
63+
test("R4: origin.* under an @sql host → ERR_ORIGIN_UNDER_SQL_BODY", async () => {
64+
const { errors } = await loadDoc({
65+
"metadata.root": { package: "acme", children: [
66+
{ "object.entity": { name: "Base", children: [
67+
{ "field.long": { name: "id" } },
68+
{ "field.string": { name: "title" } },
69+
{ "identity.primary": { name: "id", "@fields": "id" } },
70+
] } },
71+
{ "object.projection": { name: "H", children: [
72+
{ "source.rdb": { "@kind": "view", "@view": "v_h", "@sql": "SELECT id, title FROM base" } },
73+
{ "field.long": { name: "id", extends: "acme::Base.id" } },
74+
{ "field.string": { name: "displayTitle", children: [
75+
{ "origin.passthrough": { "@from": "acme::Base.title" } },
76+
] } },
77+
{ "identity.primary": { name: "id", extends: "acme::Base.id" } },
78+
] } },
79+
] },
80+
});
81+
expect(codesOf(errors)).toContain("ERR_ORIGIN_UNDER_SQL_BODY");
82+
});
83+
84+
test("R5: @filter (#207) + @sql on a projection → ERR_ORIGIN_UNDER_SQL_BODY", async () => {
85+
const { errors } = await loadDoc({
86+
"metadata.root": { package: "acme", children: [
87+
{ "object.entity": { name: "Order", children: [
88+
{ "source.rdb": { "@table": "orders" } },
89+
{ "field.uuid": { name: "id" } },
90+
{ "field.string": { name: "status" } },
91+
{ "identity.primary": { name: "id", "@fields": ["id"] } },
92+
] } },
93+
{ "object.projection": { name: "ActiveOrders", "@filter": { status: { ne: "archived" } }, children: [
94+
{ "source.rdb": {
95+
"@kind": "view",
96+
"@view": "v_active_orders",
97+
"@sql": "SELECT * FROM orders WHERE status <> 'archived'",
98+
} },
99+
{ "field.uuid": { name: "id", extends: "acme::Order.id" } },
100+
{ "field.string": { name: "status", extends: "acme::Order.status" } },
101+
{ "identity.primary": { name: "id", extends: "acme::Order.id" } },
102+
] } },
103+
] },
104+
});
105+
expect(codesOf(errors)).toContain("ERR_ORIGIN_UNDER_SQL_BODY");
106+
});
107+
108+
test("R6: origin.* under an @unmanaged host → WARN, not error", async () => {
109+
const { errors, warnings } = await loadDoc({
110+
"metadata.root": { package: "acme", children: [
111+
{ "object.entity": { name: "Base", children: [
112+
{ "field.long": { name: "id" } },
113+
{ "field.string": { name: "title" } },
114+
{ "identity.primary": { name: "id", "@fields": "id" } },
115+
] } },
116+
{ "object.projection": { name: "H", children: [
117+
{ "source.rdb": { "@kind": "view", "@view": "v_h", "@unmanaged": true } },
118+
{ "field.long": { name: "id", extends: "acme::Base.id" } },
119+
{ "field.string": { name: "displayTitle", children: [
120+
{ "origin.passthrough": { "@from": "acme::Base.title" } },
121+
] } },
122+
{ "identity.primary": { name: "id", extends: "acme::Base.id" } },
123+
] } },
124+
] },
125+
});
126+
expect(errors).toEqual([]);
127+
expect(warnings.map((w) => w.code)).toContain("WARN_ORIGIN_UNDER_UNMANAGED");
128+
});
129+
});

0 commit comments

Comments
 (0)