@@ -179,6 +179,9 @@ public static void run(MetaRoot root, MetaDataLoader loader) {
179179 // FR-015 — source.rdb @parameterRef typed-input rules.
180180 pass (collected , () -> validateSourceParameterRef (root ));
181181 pass (collected , () -> validateOnePrimarySource (root ));
182+ // #208 — DDL-ownership escape valves (@sql/@unmanaged on source.rdb).
183+ // Sibling of the source-role pass above; must run after it.
184+ pass (collected , () -> validateSourceEscapes (root , loader ));
182185 pass (collected , () -> validateRelationshipReferentialActions (root ));
183186 pass (collected , () -> validateRelationshipsM2M (root ));
184187 // ADR-0042 — the cross-package ambiguity pass (ERR_AMBIGUOUS_REF) is RETIRED. A bare
@@ -1063,6 +1066,142 @@ private static void validateObjectPrimarySource(MetaObject obj) {
10631066 }
10641067 }
10651068
1069+ // =========================================================================
1070+ // #208 — DDL-ownership escape valves (@sql / @unmanaged on source.rdb).
1071+ //
1072+ // source.rdb has two mutually exclusive, non-default DDL-ownership markers:
1073+ // @sql (a hand-written body the tool registers/fingerprints/drift-checks but
1074+ // never authors or parses) and @unmanaged (this object's DDL is owned
1075+ // elsewhere — the tool never touches it). Six fail-closed rules (design doc §5):
1076+ //
1077+ // R1 @sql AND @unmanaged on the SAME source → ERR_SQL_BODY_WITH_UNMANAGED
1078+ // R2 @sql on a writable @kind ("table", the default) → ERR_SQL_BODY_ON_WRITABLE_KIND
1079+ // R3 @sql present but empty / whitespace-only → ERR_BAD_ATTR_VALUE
1080+ // R4 origin.*-bearing own field under an @sql host → ERR_ORIGIN_UNDER_SQL_BODY
1081+ // R5 object.projection @filter (#207) + @sql host → ERR_ORIGIN_UNDER_SQL_BODY
1082+ // R6 origin.*-bearing own field under an @unmanaged host → WARN_ORIGIN_UNDER_UNMANAGED
1083+ //
1084+ // R1–R3 are per-source (declaration-layer, own-only). R4–R6 are per-host-object:
1085+ // a host with ANY own source.rdb carrying @sql/@unmanaged is judged against its
1086+ // own fields (and, for R5, its own @filter). @sql (R4, hard error) takes priority
1087+ // over @unmanaged (R6, warn) when a host declares both markers across different
1088+ // sources. Mirrors TS validate-source-escapes.ts.
1089+ // =========================================================================
1090+
1091+ static void validateSourceEscapes (MetaRoot root , MetaDataLoader loader ) {
1092+ for (MetaData rootChild : root .getChildren (MetaData .class , false )) {
1093+ if (!(rootChild instanceof MetaObject )) continue ;
1094+ validateObjectSourceEscapes ((MetaObject ) rootChild , loader );
1095+ }
1096+ }
1097+
1098+ private static void validateObjectSourceEscapes (MetaObject obj , MetaDataLoader loader ) {
1099+ // ADR-0039: own — declaration-layer source iteration (mirrors
1100+ // validateObjectSourcePhysicalNames / validateObjectPrimarySource): R1–R3
1101+ // judge markers DECLARED on this object's own sources.
1102+ List <RdbSource > sources = obj .getChildren (RdbSource .class , false );
1103+
1104+ boolean hasSqlHost = false ;
1105+ boolean hasUnmanagedHost = false ;
1106+
1107+ for (RdbSource source : sources ) {
1108+ // ADR-0039: resolving — @sql/@unmanaged are inheritable (follow the
1109+ // @role/effectiveKind precedent — sources are inheritable, NOT the
1110+ // @dbColumnType own-only exception).
1111+ boolean sqlSet = source .getSqlBody () != null ;
1112+ boolean unmanagedSet = source .isUnmanaged ();
1113+
1114+ // R1 — contradictory DDL owners on the same source.
1115+ if (sqlSet && unmanagedSet ) {
1116+ throw new MetaDataException (
1117+ ErrorMessageConstants .ERR_SQL_BODY_WITH_UNMANAGED
1118+ + ": source.rdb on object \" " + obj .getName ()
1119+ + "\" declares both @sql and @unmanaged — these are the mutually "
1120+ + "exclusive non-default states of one DDL-ownership axis (an "
1121+ + "author-supplied body contradicts \" someone else owns this DDL\" )" ,
1122+ ErrorCode .ERR_SQL_BODY_WITH_UNMANAGED , source .getSource ());
1123+ }
1124+
1125+ // R2 — @sql on a writable kind would bypass the column-diff machinery;
1126+ // tables are fully modeled or @unmanaged, never opaque-bodied.
1127+ if (sqlSet && source .isWritable ()) {
1128+ throw new MetaDataException (
1129+ ErrorMessageConstants .ERR_SQL_BODY_ON_WRITABLE_KIND
1130+ + ": source.rdb on object \" " + obj .getName ()
1131+ + "\" declares @sql with a writable @kind (\" " + source .getEffectiveKind ()
1132+ + "\" ) — @sql is legal only on a read-only kind "
1133+ + "(view/materializedView/storedProc/tableFunction); a writable table is "
1134+ + "either fully modeled or marked @unmanaged, never opaque-bodied" ,
1135+ ErrorCode .ERR_SQL_BODY_ON_WRITABLE_KIND , source .getSource ());
1136+ }
1137+
1138+ // R3 — @sql present but empty/whitespace. MUST read the raw attr, not
1139+ // getSqlBody(): getSqlBody() already narrows an empty string to null,
1140+ // which would make a present-but-empty @sql indistinguishable from an
1141+ // absent one.
1142+ if (source .hasMetaAttr (RdbSource .ATTR_SQL )) {
1143+ String rawSql = source .getMetaAttr (RdbSource .ATTR_SQL ).getValueAsString ();
1144+ if (rawSql == null || rawSql .trim ().isEmpty ()) {
1145+ throw new MetaDataException (
1146+ ErrorMessageConstants .ERR_BAD_ATTR_VALUE
1147+ + ": source.rdb on object \" " + obj .getName ()
1148+ + "\" sets @sql to an empty/whitespace-only value; @sql requires a "
1149+ + "non-empty SQL body" ,
1150+ ErrorCode .ERR_BAD_ATTR_VALUE , source .getSource ());
1151+ }
1152+ }
1153+
1154+ if (sqlSet ) hasSqlHost = true ;
1155+ if (unmanagedSet ) hasUnmanagedHost = true ;
1156+ }
1157+
1158+ if (!hasSqlHost && !hasUnmanagedHost ) return ;
1159+
1160+ // R4 / R6 — origin.*-bearing (derived) own fields under an @sql / @unmanaged
1161+ // host. ADR-0039: own — origin.* never inherits (ADR-0029), so isDerived()
1162+ // is own-only by policy (mirrors validateDerivedFieldProvidability). @sql
1163+ // (hard error) takes priority over @unmanaged (warn) when a host happens to
1164+ // declare both markers across different sources.
1165+ for (MetaField <?> field : obj .getChildren (MetaField .class , false )) {
1166+ if (!field .isDerived ()) continue ;
1167+ if (hasSqlHost ) {
1168+ throw new MetaDataException (
1169+ ErrorMessageConstants .ERR_ORIGIN_UNDER_SQL_BODY
1170+ + ": field \" " + obj .getName () + "." + field .getName ()
1171+ + "\" carries an origin.* (derived) child, but \" " + obj .getName ()
1172+ + "\" has a read source carrying @sql — the synthesized derivation and "
1173+ + "the author's verbatim SQL are two sources of truth for the same body" ,
1174+ ErrorCode .ERR_ORIGIN_UNDER_SQL_BODY , field .getSource ());
1175+ } else if (loader != null ) {
1176+ loader .addEnvelopeWarning (new LoaderWarning (
1177+ ErrorMessageConstants .WARN_ORIGIN_UNDER_UNMANAGED ,
1178+ "field \" " + obj .getName () + "." + field .getName ()
1179+ + "\" carries an origin.* (derived) child, but \" " + obj .getName ()
1180+ + "\" has a source marked @unmanaged — the tool never touches this "
1181+ + "object's DDL, so the derivation is documented lineage only (not "
1182+ + "acted on); this is informational, not an error" ,
1183+ field .getSource ()));
1184+ }
1185+ }
1186+
1187+ // R5 — a projection's row-scope @filter (#207) lowers to the outer WHERE of
1188+ // a TOOL-SYNTHESIZED body; with @sql the author owns the body (and its
1189+ // WHERE), so wrapping it is deferred cleverness (design doc D5) — reject.
1190+ if (hasSqlHost && MetaObject .SUBTYPE_PROJECTION .equals (obj .getSubType ())) {
1191+ // ADR-0039: own — the @filter is declared locally on this projection
1192+ // (mirrors validateProjectionFilter).
1193+ if (obj .hasMetaAttr (MetaObject .ATTR_FILTER , false )) {
1194+ throw new MetaDataException (
1195+ ErrorMessageConstants .ERR_ORIGIN_UNDER_SQL_BODY
1196+ + ": projection \" " + obj .getName ()
1197+ + "\" declares both @filter and an @sql read source — a view-level "
1198+ + "@filter lowers to the outer WHERE of a synthesized body; with @sql "
1199+ + "the author owns the body (and its WHERE), so the two cannot be combined" ,
1200+ ErrorCode .ERR_ORIGIN_UNDER_SQL_BODY , obj .getSource ());
1201+ }
1202+ }
1203+ }
1204+
10661205 // =========================================================================
10671206 // Relationship @onDelete / @onUpdate enum validation
10681207 //
0 commit comments