Skip to content

rfc: schema namespaces and multi-file schemas - #1015

Open
ragnorc wants to merge 6 commits into
ModernRelay:mainfrom
ragnorc:rfc/schema-namespaces
Open

ragnorc wants to merge 6 commits into
ModernRelay:mainfrom
ragnorc:rfc/schema-namespaces

Conversation

@ragnorc

@ragnorc ragnorc commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

What & why

This adds a draft RFC, Schema namespaces and multi-file schemas. It covers two things:

  • Multi-file schemas. One graph's schema can be authored as several .pg files. They are composed into one canonical source before hashing, so no storage, IR, ledger or wire format changes.
  • Namespace blocks. namespace crm { … } gives the canonical name crm::Contact. The namespace is part of the name and never part of identity: moving a type into a namespace is a declared rename on the existing RenameType/RenameTable path.

Today a schema is one file everywhere (parse_schema(&str), GraphConfig.schema: PathBuf), and type and edge names are global.

Backing issue / RFC

Checklist

  • Change is focused (one logical change): one new draft RFC plus its registry row
  • Tests added/updated for behavior changes (or N/A): N/A, documentation only; the RFC lists the test owners to extend
  • Public docs updated if user-facing surface changed (or N/A): N/A, draft RFC
  • Reviewed against docs/dev/invariants.md: the RFC's Invariants section maps invariants 2, 4, 6, 8, 9, 12 and 13

Local verification

  • python3 scripts/check-docs.py: Documentation OK (308 Markdown files checked).
  • typos docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md docs/rfcs/README.md: no findings

Notes for reviewers

Code facts at fe69c039 that shape the design

  • Schemas are identified by their source bytes. Everything downstream of the loaders identifies a schema by sha256(source bytes): the contract source_hash, the cluster schema.<graph> resource, deployment.rs's resource/contract invariant, and drift checks. Composing before hashing keeps all of them unchanged. Keeping file boundaries after apply would need a stamp bump, so the RFC defers it.
  • Type names stay out of data paths but are persisted elsewhere. Data paths, Lance field names and index names never contain a type name. The name is persisted as the table_key alias in __manifest rows and immutable __history extents. Renames already rewrite it metadata-only, so the chosen spelling (node:crm::Contact) is permanent history, not a format change.
  • A separate namespace field in SchemaIR was rejected. The planner's unclassified-change refusal would reject it, and it would change IR uniqueness and sort keys. A namespaces feature name keeps older binaries refusing cleanly instead.
  • Three parsers split on the first :. These are the CLI embed --select selector and the GQT expect plan and expect shape parsers. They need fixing with the namespace phase.
  • Lance's namespace specification is a table catalog, not a module system. Using it for types would derive storage from names, against invariant 6.

Dependencies

Composition with Polymorphic types (#884)

The RFC has a section on this. In short:

  • one qualified type-reference rule for unions, implements, type tests and typed endpoints;
  • interfaces span namespaces;
  • moves keep StableTypeId and @type_id;
  • rfc: polymorphic types with interfaces, unions and polymorphic edges #884's registry pinning is the guard against a name re-resolving to another type;
  • namespaces confer no visibility, as implements confers none.

Engine catalogue (#1008)

Built-in @ types stay outside namespaces.

Prior art read

Gel modules, Prisma multi-file schemas, PostgreSQL search_path (CVE-2018-1058), Protocol Buffers and Buf, GraphQL's namespace discussion, ISO GQL, Neo4j graph types, Terraform moved blocks.

Unresolved questions

  • the scope of use;
  • file banners in the composed text;
  • recursive discovery;
  • a bulk-move form;
  • the case rule for namespace segments;
  • namespace feed filters.

A draft RFC for authoring one graph's schema as several .pg files, composed into one canonical source before hashing, and for namespace blocks whose canonical names (crm::Contact) are part of the name and never of identity. It records how the design composes with the polymorphic types draft.
- ragnorc
created: 2026-10-15
updated: 2026-10-16
discussion: null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (1 of 4): link #838 and record its disposition.

Issue #838 ("schema: native imports for composing .pg files") is open and has the accepted label. It asks for import syntax in .pg files. This RFC rejects import (Alternatives table, line 526), but it does not link #838. CONTRIBUTING.md says the existing issue tracks the RFC ("Maintainers apply needs-rfc to the existing issue, which then tracks the RFC").

Please do these three things:

  1. Set discussion: to schema: native imports for composing .pg files #838, or to the issue that maintainers choose.
  2. Add a short disposition: this RFC replaces the import design of schema: native imports for composing .pg files #838.
  3. Say which schema: native imports for composing .pg files #838 needs this RFC keeps and which it drops:
    • Kept: split one schema into files; share a core file between graphs of one cluster (a list entry); errors with a location; the server never opens caller paths (the bundle carries the composed source).
    • Dropped: named (selective) imports; a .pg file that you can check alone (an editor or a one-file lint cannot resolve core::Named in crm.pg without the config); sharing a core file across clusters, because check_config_path refuses ...
    • Not stated: schema: native imports for composing .pg files #838 says "a definition reached through several paths is included once". Here, one file reached twice (for example [schema/core.pg, schema/]) gives a duplicate-declaration error. That is a valid choice, but please state it.

Comment on lines +384 to +386
declaration among the root and the `use`d namespaces, or it is an error. Every
name is bound at compile time to a `StableTypeId` in the typed IR, so a later
schema change can produce a loud plan-time error but never a different binding.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (3 of 4): this claim does not match the code at this head.

Query IR and execution use type names, not StableTypeId:

  • IROp::NodeScan { type_name: String } and IROp::Expand { src_type: String, dst_type: String } (crates/omnigraph-compiler/src/ir/mod.rs).
  • The planner and engine build the table key from the name: format!("node:{type_name}") in crates/omnigraph/src/engine/scan.rs:153, engine/operators/scan/input.rs:252 and crates/omnigraph-planner/src/source.rs:367; format!("edge:{edge_type}") in engine/graph.rs:637.
  • No StableTypeId or type_id appears in omnigraph-compiler/src/query/, omnigraph-compiler/src/ir/ or omnigraph-planner/src/.

The Invariants section repeats the claim (line 498: "execution sees only type ids").

The safety argument can still hold, but it rests on other mechanisms. Please restate it on the mechanisms that exist:

  1. One snapshot per query (invariant 3), so the name lookup and the catalog agree.
  2. Stored queries are typechecked again at cluster plan (validate_query_source in omnigraph-cluster/src/config.rs), at apply (deployment/execution.rs:791) and at server load (omnigraph-server/src/queries.rs:265).
  3. The resolver gives an exact (kind, name) match priority over a hint (resolve in catalog/schema_ir.rs). So one apply cannot release a name and reuse it with the same kind. I ran this at the head: node Beta @rename_from("Alpha") plus a new node Alpha in one plan gives accepted node 'Alpha' is consumed by more than one desired declaration.
  4. The exactly-one rule.

If you want ids in the query IR, list that as new work in Rollout and Tests. It changes the planner and the engine, not only the compiler.

Comment on lines +329 to +330
- Bundles keep carrying one composed source, so remote `cluster plan` and
`cluster apply` need no wire change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (4 of 4): the list form changes a wire field and a stored file.

CapturedDeployment.config_semantics is the JSON of the whole RawClusterConfig, and that includes graphs.<g>.schema (crates/omnigraph-cluster/src/deployment.rs:123-130; built at config.rs:1201). This value travels:

  • to the server, in PlanRequest and DeploymentRequest (crates/omnigraph-server/src/deployment.rs:100-112, both deny_unknown_fields);
  • into cluster storage, as deployment/{digest}.json (write_deployment_bundle in store.rs).

validate_bundle parses it again into RawClusterConfig and compares the re-serialization (deployment/execution.rs:647-656).

Result:

  • schema: schema/ (one path) stays a string, so an older server accepts it. The server reads only the composed source (source_for(bundle, schema_address)), so this form works across versions.
  • schema: [a.pg, b/] (a list) is an array where an older server expects a PathBuf. The older server refuses with deployment_input_invalid. An older binary that reads a stored bundle refuses the same way.

This fails closed, so it is safe. But the Summary (lines 29-31), this bullet and Compatibility (line 506) say there is no ledger or wire change. Please correct them and state the skew rule: which server version accepts the list form, and what an older one returns.

Also, ResourceSummary.path holds one path per resource (config.rs:894; public in types.rs:85). Please say what it shows for a multi-file schema in cluster validate/plan --json, because that output is a compatibility surface too.

Comment on lines +282 to +285
- **Prerequisite: refuse dropping populated types.** A move without
`@rename_from` plans today as `DropType` plus `AddType`, and a populated drop
is accepted silently (issue #984). With namespaces that mistake becomes easy,
so populated drops must fail closed, owned by #984, before namespaces ship.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (2 of 4): the prerequisite has no owner that agrees with it.

The current-behavior claim is true. I ran it at this head with the quickstart data (1 Claim, 2 Source, 2 Supports):

$ omnigraph schema plan --schema drop.pg g.omni   # drop.pg keeps only `node Claim { slug: String @key }`
schema plan for g.omni
supported: yes
- drop property 'Claim.statement' of node 'Claim'
- drop node type 'Source'
- drop edge type 'Supports'

But #984 has only the needs-triage label, and its suggested fix goes the other way: remove OG-DS-102/103/104 from the docs and "state plainly that drops of populated declarations are supported". So #984 does not own "populated drops fail closed" today. Also, the frontmatter has blocked_on: []. docs/rfcs/README.md says blocked_on holds dependency gates, and that hidden shared dependencies block acceptance.

Please choose one:

A narrower option to weigh: refuse only the likely move. That is a plan that drops a populated X and adds ns::X (same final segment, same kind) with no @rename_from. It fails closed and infers no identity, so invariant 6 holds. Ordinary drops keep working. The cost is one more planner rule and one more refusal code.

collected before resolution, and edge endpoints are checked after every node
is known. Joining files therefore cannot change meaning.

Tests extend the existing owners in [testing](../dev/testing.md):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: add refusal and compatibility evidence to the test plan.

Invariant 13 asks for compatibility and refusal evidence for a hard-to-reverse decision, and the node:crm::Contact spelling stays in immutable __history extents. Existing owners make this cheap:

  • crates/omnigraph/src/db/catalog_tests.rs (around line 500) already proves that an unknown feature name refuses open before any effect. Add namespaces to that test, so the claim at lines 357-359 stays pinned. (I checked the mechanism: the envelope gate in schema_state.rs:45-56 runs before the source parse, so an older binary reports the feature, not a parse error.)
  • A cluster test that sends the list form through validate_bundle. It pins the skew rule from my comment on line 329.
  • .gqt cases under crates/omnigraph-gqt/cases/ for the ambiguity and unknown-namespace errors. They show in errors, and AGENTS.md makes .gqt the default owner for query behavior.
  • A row that proves a property named namespace or use, an edge Use and the alias as use keep parsing. I ran this at the head and all four work today, so it is a real regression guard for the "no reserved word" promise.

Comment on lines +111 to +116
- The composed source is each file's bytes in order. Each file is preceded by
a banner line `// file: <path>` and ends with exactly one newline.
- `<path>` is relative to the declared directory, or to the `cluster.yaml`
directory for list entries.
- A schema declared as one file is composed as that file's bytes with no
banner, so every existing graph keeps its digest.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: make the banner path one rule.

The banner text goes into source_hash and the schema.<graph> digest. So its format becomes permanent for every multi-file graph. A later change gives each of them a new digest. I see two gaps in the current rule:

  1. A directory inside a list is both a "declared directory" and a "list entry". If its banners are relative to the declared directory, [schema/crm/, schema/hr/] can give two banners // file: a.pg.
  2. schema: schema/ and schema: [schema/] name the same files. If their banners differ, the same files give a new digest, a new contract with no steps, and drift.

Suggestion: use the path that query discovery already records, declared.join(file_name) relative to the cluster.yaml directory (omnigraph-cluster/src/config.rs:253), with / as the separator. Then one file has one banner in every form. For CLI --schema <dir>, use the same rule relative to the current directory, or state that CLI and cluster digests can differ for the same files.

Please also say whether a one-element list counts as "declared as one file" (no banner).

Comment on lines +263 to +264
- Type names stay Cedar entity ids (strings) and never become Cedar entity
types, because Cedar already uses `::` for those (`Omnigraph::…`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: two small corrections about current code.

  • Here (line 263): no type name is a Cedar entity today. The policy engine builds only Actor, Action, Group, Graph, Server and Cluster entities (crates/omnigraph-policy/src/lib.rs:635-644, entity_uid at line 999). "Stay" reads as a current fact. Suggested text: "If per-type policy (rfc: granular permissioning #951) lands, type names are Cedar entity ids, never entity types, because Cedar already uses :: for those."
  • Line 70: the .gq type_name already admits the @ prefix (type_name = @{ "@"? ~ ASCII_ALPHA_UPPER ~ … }, query.pest:260, from feat(query): expose engine catalogue as built-in types #1008). Only the .pg rule is as quoted.

Comment on lines +417 to +419
In a union, each member resolves on its own. The grammar has one
`type_ref = (segment "::")* type_name` rule that both RFCs use, so they never
grow two spellings.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: two grammar details.

  1. type_ref already exists in both grammars as the property-type rule (schema.pest:38 type_ref = { core_type ~ "?"? }, query.pest:138 for parameter types). Please give the shared rule a new name, for example qualified_type_name, so the two RFCs do not collide with it.
  2. Today an edge that a reserved word names is written as a string: $i "in" $b (query.pest:163-166). The list at lines 216-219 does not give the qualified form. Please choose $i "crm::in" $b or $i crm::"in" $b, and say whether in/and/or/not/is/null after :: stay reserved.


```pg
namespace crm {
node Contact @rename_from("Contact") { … } // was the root Contact

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: say how a @rename_from hint resolves.

The resolver looks up a hint as an accepted name (accepted_by_name.get(hint) in resolve, catalog/schema_ir.rs). But inside namespace crm, the reference rule at line 174 reads an unqualified Contact as crm::Contact or the root Contact. A reader can apply that rule to the hint string too.

Please state that a hint is always the canonical name, and the in-namespace rule does not apply to it. For example: @rename_from("Contact") always means the root type, and @rename_from("crm::Contact") moves a type out of crm. The same sentence can say that the IgnoredExactMatchRenameHint diagnostic still applies when the new canonical name already exists.

Comment on lines +104 to +109
- **Refusals and caps.**
- Symlinks and `..` components are refused (`check_config_path`).
- Reads go through `SourceCapture`, with the same per-file and total byte caps
and the same discovery cap as queries.
- A directory with no `.pg` file is an error, not a warning, because a graph
has to have a schema.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: state the real size limit, and the one rule that differs from queries:.

  • Each file read has the MAX_CONFIG_SOURCE_BYTES cap (1 MiB). But validate_bundle also refuses any one bundle source above 1 MiB (deployment/execution.rs:619-621), and the composed schema is one source. So the whole composed schema, banners included, stays at 1 MiB, as one file is today. The 8 MiB total cap does not raise it. The motivation is authoring, not size, so this limit is probably fine. Please state it, so a reader does not expect the total cap to apply.
  • Line 98 says discovery copies the queries: rules "exactly". Line 108 makes an empty directory an error, but for queries it is the warning query_dir_empty (config.rs:242-250). Please write "with one difference".

@cursor

cursor Bot commented Oct 16, 2026

Copy link
Copy Markdown

Review: RFC "Schema namespaces and multi-file schemas" at 9e446fd91a001a577487c6466416b7be83a6d573

Recommendation: request changes before acceptance. The design direction is sound and most claims about current code are true. Four items need a fix: one process gap and three statements that are not true at this head. Six optional items follow. Every item has an inline comment.

(I posted this as comments through the tool I have. It cannot set a formal review state, so this text states the outcome.)

What the RFC does

  • Why: a schema must be one .pg file, and type and edge names are global. Large schemas and copied templates collide.
  • What: two decisions that can ship separately. (1) Several .pg files compose in a fixed order into one source text before hashing. (2) namespace crm { … } blocks give canonical names such as crm::Contact. The namespace is part of the name, never part of identity. A move is a declared rename.
  • How: reuse QueriesDecl-style discovery and the SourceCapture caps, the existing RenameType/RenameTable path, and a derived namespaces IR feature name.
  • Impact: additive grammar, no storage stamp. A single-file graph keeps its digests. The spelling node:crm::Contact becomes permanent in __history.

Blocking

  1. schema: native imports for composing .pg files #838 is not linked or decided (line 11). It is an accepted issue that asks for import. This RFC rejects that design but gives no disposition.
  2. The docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984 prerequisite has no owner that agrees with it (line 282). docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984 has only needs-triage and proposes to document populated drops as supported. blocked_on is empty.
  3. "Bound to a StableTypeId in the typed IR" and "execution sees only type ids" are not true (line 384; also line 498). The IR and the engine use names. The safety argument still holds, but on other mechanisms.
  4. "No ledger or wire change" is not true for the list form (line 329). config_semantics carries graphs.<g>.schema to the server and into stored bundles. An older server refuses an array.

Optional

  1. One file can get two banners, and the same files can get two digests (line 111).
  2. The real composed size limit is 1 MiB; the empty-directory rule differs from queries: (line 104).
  3. State that a @rename_from hint is always a canonical name (line 270).
  4. type_ref already names a rule in both grammars; give the quoted-edge form (line 417).
  5. Two small factual corrections: Cedar entities, and the .gq type_name rule (line 263).
  6. Add refusal and compatibility tests to the existing owners (line 553).

Tradeoffs and long-term cost

  • Compose before hash: good. One source text keeps every digest consumer unchanged (source_hash, schema.<graph>, the resources[schema] == contract.source_hash check in deployment.rs:432). The cost is that the banner format becomes a hashed format. Decide it before acceptance.
  • Namespace in the name, not in identity: good. It matches invariant 6 and adds no IR keys. The permanent cost is :: in __history table keys and in every wire value. I found no other production split(':') on a type or table key, so the RFC's list of parsers to fix is complete for Rust code (omnigraph-cli/src/embed.rs:395, omnigraph-gqt-core/src/plan.rs, shape::is_type_name).
  • Composition in config, not import: defensible. It keeps one authority and needs no cycle rules. The cost is that one .pg file does not describe itself. Please name that cost in the Alternatives row.
  • Exactly-one name resolution: good. It is stricter than search_path. Adding a type can break a stored query at plan time, but it cannot change the query's meaning silently.
  • Fail-closed populated drops: high cost as written. It changes a supported behavior for every graph. A narrower refusal of the likely-move pattern costs less (see line 282).
  • After five more changes of this kind: the design holds, because more namespaces, files and wire fields reuse one string and one rename path. The weak points are the hashed banner format and the open docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984 decision.

Claims I checked against the code (all true unless listed under Blocking)

  • parse_schema(&str); GraphConfig.schema: PathBuf; untagged QueriesDecl with top-level sorted discovery, symlink refusal and per-entry caps (config.rs:118-330).
  • .pg type_name grammar; :: appears in neither grammar; normalize_edge_name is to_lowercase() (catalog/mod.rs:339).
  • Contract source_hash is sha256(source) (db/omnigraph.rs:292); validate_schema_contract_row parses the stored source on open; schema AST declarations have no span.
  • The resolver matches by (kind, name), then by hint; the planner has an unclassified-change refusal (schema_plan.rs:191); features derive edge-keys and keep system-columns; an unknown feature refuses open before effects (catalog_tests.rs:~500).
  • SchemaShape sorts types by name before ids are given (schema_shape.rs:270-272), so file order cannot change identity allocation.
  • table_id_to_key refuses ids with more than one component; data paths derive from ids (table_path_for_identity); bind_catalog_aliases binds historical keys by identity; change-feed tokens carry a filter digest; graph-index stamps carry edge and type names.
  • Stored queries are typechecked at cluster plan, at apply and at server load.
  • Lance 11.0.0: Schema::validate refuses . in a top-level field name (lance-core-11.0.0/src/datatypes/schema.rs:335); the default index name is {column_path}_idx (lance-11.0.0/src/index/create.rs:287). The Lance namespace spec uses $ as its default delimiter, and its V2 directory catalog stores tables as <hash>_<object_id>.
  • The rfc: polymorphic types with interfaces, unions and polymorphic edges #884 text matches what this RFC says about it (interface @rename_from, registry pinning by kind and StableTypeId, same-apply name-reuse refusal).

Validation commands and results

git worktree add /tmp/pr1015 9e446fd91a001a577487c6466416b7be83a6d573   # clean, base 864495a8
cargo build -p omnigraph-cli --locked              # Finished, exit 0
cargo test -p omnigraph-compiler --locked --lib    # 483 passed; 0 failed
python3 scripts/check-docs.py                      # Documentation OK (308 Markdown files checked).
typos 1.50.1 (CI pin) on the two changed files     # no findings

Runtime checks with that CLI binary, on a local graph loaded with the quickstart data:

  • Populated drop: schema plan gives supported: yes and drops property, node and edge. This confirms the docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984 claim.
  • Comment-only edit: schema apply --json gives applied: true, step_count: 0, graph_manifest_version: 3. The same text again gives applied: false. This confirms "moving text between files publishes a new contract with no schema steps".
  • namespace crm { fails with expected schema_file. edge WorksOn: Source -> crm::Contact fails with expected type_name. Both additions are new syntax.
  • A leading use crm; line in .gq fails with Q001 parse error: expected query_file.
  • A property named namespace or use, an edge Use and the alias as use all parse, typecheck and run today.
  • node Beta @rename_from("Alpha") plus a new node Alpha in one plan is refused: accepted node 'Alpha' is consumed by more than one desired declaration.

Limits of this review

  • I did not run the server, a remote cluster plan or apply, or an old/new version pair. Blocking item 4 comes from reading validate_bundle and the serde types, not from a run.
  • rfc: polymorphic types with interfaces, unions and polymorphic edges #884 and rfc: granular permissioning #951 are open drafts. I read them at their current heads (d9caeb6, 44daa2c); they can change.
  • I read the Lance table-schema page, the namespace spec pages and the pinned Lance source for the two Lance claims. The RFC makes no other Lance claim, so I did not read the other Lance domains.
  • I did not run the full workspace test gate. This PR changes only Markdown.

…tity

Registry pinning covers every resolved type and edge name, and a same-kind
re-add in one apply is caught by pinning alone; ad-hoc text is not
protected. A move respells integer-identity handles, which parse at their
last colon. Adds the GQT load-plan and type/cast parsers to the fix list,
view-catalog name resolution from Actor views, and the block form of a
namespaced interface declaration.
Links issue ModernRelay#838 and records its disposition, replaces the ModernRelay#984 prerequisite with a narrow refusal of a probable move without @rename_from, restates name-resolution safety on the mechanisms the query IR actually has, states the list-form skew rule, fixes one banner path rule and the composed size bound, and adds refusal and compatibility tests.

ragnorc commented Oct 18, 2026

Copy link
Copy Markdown
Contributor Author

Review of the RFC at 7565f5ff

Recommendation: request changes before acceptance.

The head named in the request was 2e672c0c. It moved before this review was posted. This review covers the new head 7565f5ffb19d3150ae3c1857bf43b7a28592f492. Its parent is 2e672c0c. The only change is the RFC. No other review covers this head.

What this change does

Why. A schema is one .pg file. Type names and edge names are global. Large schemas and copied templates collide.

What. Several .pg files join into one source text before the hash. A namespace block makes the canonical name crm::Contact. The namespace is part of the name. A move is a declared rename.

How. Reuse query-file discovery, the existing rename path, and one feature name, namespaces.

Impact. A one-file graph keeps its digests. The list form is new config syntax. The spelling node:crm::Contact stays in __history.

Commit 7565f5ff answers the review at 9e446fd9. Nine of those ten items are fixed. Two gaps remain in the new text.

Earlier findings

  1. schema: native imports for composing .pg files #838. Fixed. discussion is issue schema: native imports for composing .pg files #838. The new section records what this RFC keeps, drops, and decides differently. The alternatives row names the cost. One .pg file does not describe itself.

  2. docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984. Fixed as a narrow rule this RFC owns. It no longer waits on docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984. A populated drop plus an add of the same kind and final segment, in another namespace, with no @rename_from, is refused. Ordinary populated drops stay as they are. schema_plan.rs still emits DropType with no population check, so that current-behavior sentence is true. The rule misses one edge case. See blocking item 2.

  3. Type ids in the query IR. Fixed in the resolution section. Lines 486-508 and invariant 9 now say the IR carries canonical names. That matches the code. IROp::NodeScan and IROp::Expand store type_name. The planner and the engine look up node:{type_name}. There is no StableTypeId under omnigraph-compiler/src/query/, omnigraph-compiler/src/ir/, or omnigraph-planner/src/. The four mechanisms match the code:

    • one snapshot per query
    • validate_query_source at plan (config.rs line 1288) and at apply (deployment/execution.rs line 791), and server load (queries.rs line 265)
    • exact (kind, name) match before the hint (schema_ir.rs lines 642-691)
    • the exactly-one rule

    The pinning paragraph still contradicts item 3 of that list. See blocking item 1.

  4. List form and the wire. Fixed in Summary and Compatibility. A single path stays a string in config_semantics. The list form is a JSON array. validate_bundle reparses that JSON into RawClusterConfig (deployment/execution.rs lines 647-648). An older server refuses the array with deployment_input_invalid. ResourceSummary does not deny unknown fields, so an optional paths field is additive. One design sentence still says there is no wire change. See optional item 3.

  5. Banner path. Fixed, with one citation to add. Query discovery records a directory entry as declared.join(file_name) (config.rs line 253) and a file entry as the declared path (config.rs line 257). One file, in any declaration form, has no banner. The three forms in the test plan compose the same bytes when a file entry uses the declared path. Please cite both arms. An implementer who only copies join turns schema/a.pg into schema/a.pg/a.pg.

  6. Size limit and the empty directory. Fixed. MAX_CONFIG_SOURCE_BYTES is 1 MiB (config.rs line 10). validate_bundle refuses one source above that (line 621). An empty query directory is the warning query_dir_empty (line 244). The RFC now states the difference.

  7. Rename hint. Fixed. The hint is the canonical accepted name. The in-namespace rule does not apply. IgnoredExactMatchRenameHint is named.

  8. Grammar. Fixed. The shared rule is qualified_type_name, not type_ref. type_ref remains the property-type rule (schema.pest line 40, query.pest line 138). The quoted form is $i "crm::in" $b. The bare form is $i crm::in $b. A namespace segment cannot be a reserved word. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 shares the spelling crm::Contact. It does not yet name this pest rule. This RFC can own the name.

  9. Cedar and the .gq type_name. Fixed. No type name is a Cedar entity today. entity_uid builds Omnigraph::{type} (omnigraph-policy/src/lib.rs line 1000). The .gq rule admits @ (query.pest line 260). The .pg rule does not (schema.pest line 66).

  10. Tests. Fixed. The plan adds the unknown-feature row, the list-form bundle test, the .gqt error cases, and the row for namespace, use, edge Use, and alias as use.

Blocking

1. The two safety sections disagree.

Lines 496-500 say one apply cannot release a name and reuse it with the same kind. The example is refused as "consumed by more than one desired declaration". That matches resolve (schema_ir.rs lines 642-691). Both desired declarations select the same accepted type, in either order.

Lines 569-583 say the same pattern is caught by pinning alone, and that the re-add retargets ad-hoc text. Those sentences are false while lines 496-500 are true. The apply does not publish. Pinning does not run. Ad-hoc text does not retarget.

Pinning matters on a later apply. Apply 1 renames OwnedBy to crm::OwnedBy. Apply 2 adds a new root OwnedBy. The resolver allows that, because the two declarations are not in one plan. A stored query that still says OwnedBy then typechecks against the new id. validate_query_source accepts a name that resolves. It does not compare ids. Pinning is the guard for that second apply, and only for stored queries. Ad-hoc text does retarget on that second apply.

Please make the two sections say this. Delete "caught by pinning alone" for the one-apply example. #884's migration table does not have to allow that example. resolve already refuses it.

The pin list is also shorter than #884 at 161336e6 (lines 725-728 of that draft). #884 records rebindings and alternation members, and it refuses a result-descriptor change. Please copy that list.

2. The probable-move rule misses an edge case fold.

The new refusal compares the final segment as written. Edge identity folds case. normalize_edge_name is to_lowercase() (catalog/mod.rs line 351). A drop of populated OwnedBy plus an add of crm::ownedby, with no @rename_from, is the same edge after that fold. An exact compare does not catch it, and the rows are dropped. Compare edge segments with that fold. Node names do not need it. They already start with an uppercase letter.

Optional

1. A move can change an explicit order. Ties break on the packed reference, so those ties do not move. That matches integer identity and polymorphic types at 161336e6. An order or a resume filter on @type, or on the public @id string of an unkeyed node, uses the name. A move changes Note:10 to crm::Note:10. Say that those orders change.

2. expect plan has one more gate. The split claims are true. plan.rs line 271 uses rsplit_once(':') for type and cast. Line 503 uses split_once(':') for scan and expand, so the first : inside crm::Contact splits the selector. After that split is fixed, expand edges and selection members still use identifier, which rejects :. Please add it to the fix list. Load-plan type_name matches the charset claim. The first character must be a letter (load_plan.rs lines 265-269).

3. One design sentence still says there is no wire change. Lines 429-430 say remote cluster plan and cluster apply need no wire change. Compatibility already states the list-form skew rule. Point this sentence at that rule, or limit it to the composed source and the single-path form.

4. Namespace-pattern policy disagrees with actor views. The view-catalog sentence matches #951 at 7f3d601e. The next bullet, and rollout item 4, still offer crm::* policy sugar. The same #951 paragraph says that sugar contradicts D8. Access is a placement fact, not a rule that names types. Please delete the sugar, or say that #951 rejects it.

5. Cite both banner path arms. See earlier finding 5.

Alignment that holds

Tradeoffs and long-term cost

Compose-before-hash keeps one digest for every consumer of source_hash. The banner text is now part of that digest. The RFC says a later banner change gives every multi-file graph a new contract with no schema steps. That is the right warning.

The narrow refusal is the right scope. It does not change drops for graphs that have no namespace. It must use the edge case fold, or the mistake it exists to catch remains for edges.

The resolver and the registry pin are two layers. The resolver owns one apply. The pin owns a later apply, and only for stored queries. Ad-hoc text has no pin. Saying that removes the contradiction. It also states the real liability. An ad-hoc query follows the new name on the next run.

Namespace-pattern policy would be a second access model beside the placement facts in #951. Five more pattern sugars would rebuild query-based policy. Drop the sugar.

After five more changes of this kind, one string and one rename path still hold. The weak point left is the disagreement between the two safety paragraphs.

Regression proof

This PR changes documents only. It does not change rows, counts, column types, or errors. GQT cannot express the parser claims or the probable-move refusal until the grammar exists. Those claims are visible in the parser and resolver source. I did not add a temporary .gqt case. I did not run the GQT corpus.

Validation

  • Reviewed head: 7565f5ffb19d3150ae3c1857bf43b7a28592f492. Parent: 2e672c0cadca9633c9228f05bb90f0b4d38e2379. Merge-base with main: 0a62bb7b2aa5b99b012f413807c216277f7f9596.
  • python3 scripts/check-docs.py in a clean worktree at that head: Documentation OK (342 Markdown files checked).
  • typos 1.50.1 on the RFC and docs/rfcs/README.md: no findings.
  • The RFC commit does not change Rust. The code citations are from that parent tree, which is the tree the sentences describe.
  • Lance 11.0.0, for the unchanged claims only. Schema::validate refuses . in a top-level field name (lance-core-11.0.0/src/datatypes/schema.rs line 335). The default index name is {column_path}_idx (lance-11.0.0/src/index/create.rs line 287). The delta adds no Lance claim.
  • Related heads read for the alignment: rfc: polymorphic types with interfaces, unions and polymorphic edges #884 161336e63370c0f3575bf5fd1523d0efd2b0fac4, rfc: granular permissioning #951 7f3d601e634d07b17eb7646c8280c15d550ad8c5, rfc: schema apply with live branches #963 4fb15738575df15aa29c61d79fc78c4e2ef6e28d.

Limits

Inline comments

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 571

Blocking. This sentence contradicts the resolver rule at lines 496-500.

resolve matches an exact (kind, name) before the hint (schema_ir.rs lines 642-691). Both of these desired edges select the accepted edge OwnedBy:

  • edge crm::OwnedBy @rename_from("OwnedBy")
  • a new root edge OwnedBy

The second match fails with accepted edge 'OwnedBy' is consumed by more than one desired declaration. The apply does not publish. Pinning does not run. Ad-hoc text does not retarget.

Pinning matters on a later apply. Apply 1 renames OwnedBy to crm::OwnedBy. Apply 2 adds a new root OwnedBy. The resolver allows that pair, because they are not in one plan. A stored query that still says OwnedBy then typechecks against the new id. validate_query_source accepts a name that resolves. It does not compare ids. Pinning is the guard for that second apply, and only for stored queries. Ad-hoc text does retarget on that second apply.

Please make lines 496-500 and this paragraph say that. Delete "caught by pinning alone" for the one-apply example.

Optional, same paragraph. #884 at 161336e6 also records rebindings and alternation members, and it refuses a result-descriptor change. Please copy that list. This parenthetical omits the first two.

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 374

Blocking. Compare edge segments with the existing case fold.

normalize_edge_name is to_lowercase() (catalog/mod.rs line 351). A drop of populated OwnedBy plus an add of crm::ownedby, with no @rename_from, is the same edge after that fold. An exact compare of the final segment does not catch it, and the rows are dropped.

Please say the final segment is compared after that fold for edges. Node names do not need the fold. They already start with an uppercase letter.

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 561

Optional. The tie-break sentence is true. The result sentence is too wide.

A tie breaks on the packed reference, so a move does not change that tie order. Integer identity and polymorphic types at 161336e6 say the same thing.

An order or a resume filter on @type, or on the public @id string of an unkeyed node, uses the name. A move changes that string from Note:10 to crm::Note:10. Please say that those orders change.

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 319

Optional. One more gate rejects :: after the colon split is fixed.

The split claims are true. plan.rs line 271 uses rsplit_once(':') for type and cast, so crm::Contact is cut before Contact. Line 503 uses split_once(':') for scan and expand, so the first : inside crm::Contact splits the selector.

Expand edges and selection members then use identifier, which rejects :. Please add identifier to this list. Load-plan type_name matches the charset claim. The first character must be a letter (load_plan.rs lines 265-269).

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 430

Optional. This sentence is wider than the skew rule.

Compatibility says the list form is a JSON array and an older server refuses it with deployment_input_invalid. That is right. validate_bundle reparses config_semantics into RawClusterConfig (deployment/execution.rs lines 647-648), and GraphConfig.schema is a PathBuf.

Please point this sentence at that rule, or limit it to the composed source and the single-path form. The composed source is one bundle field. The list form still changes config_semantics.

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 335

Optional. Actor views rejects this sugar.

The view-catalog sentence above matches #951 at 7f3d601e. The same paragraph of that draft says a namespace-pattern policy rule contradicts D8. Access is a placement fact. It is not a rule that names types.

Please delete this bullet and rollout item "Namespace-scoped per-type policy rules", or say that #951 rejects them.

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md line 157

Optional. Cite both arms that query discovery records.

A directory entry is declared.join(file_name) (config.rs line 253). A file entry is the declared path itself (config.rs line 257), not a join.

schema: schema/, schema: [schema/], and [schema/a.pg, schema/b.pg] compose the same banner bytes only when the file arm keeps schema/a.pg. An implementer who joins every entry produces schema/a.pg/a.pg and a different digest.

@cursor

cursor Bot commented Oct 18, 2026

Copy link
Copy Markdown

Review of the four-line delta at 9c360b5a

Recommendation: request changes before acceptance.

This is a comment. It does not set a formal review state. The recommendation is request changes.

The review at 7565f5ff (#1015 (comment)) still applies. Commit 9c360b5a adds four lines. It does not edit the two blocking paragraphs or the six optional paragraphs. No other review covers this head.

What this commit does

Why. The RFC already says that type ids in the query IR are outside this design. Readers need one owner for that later change.

What. The new lines link issue #1079. They call that issue phase 0 of Polymorphic types. They say namespaces do not depend on it. They say a move becomes cheaper, because id-keyed derived state, such as the graph-index topology cache, no longer rebuilds after a rename.

How. A documentation link only. No grammar, storage, or code change.

Impact. The current safety argument stays on names. The link records future work. It does not make execution use type ids today.

Status of each finding from the review at 7565f5ff

All eight findings are still open. The new lines do not touch them.

  1. Blocking. The two safety sections disagree. Still open. Lines 496-500 still say one apply cannot release a name and reuse it with the same kind. Lines 573-575 still say that same-kind re-add is caught by pinning alone. Lines 585-587 still say that re-add retargets ad-hoc text. Those accounts cannot both be true for one apply. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 at a4df45c9 still says the same thing at its lines 731-736. Linking rfc: resolve type references to StableTypeId below the compiler #1079 does not change the resolver or the pin.

  2. Blocking. The probable-move rule misses the edge case fold. Still open. Lines 373-376 still compare the final segment as written. normalize_edge_name is to_lowercase().

  3. Optional. A move can change an explicit order. Still open. Lines 564-565 still say a move does not reorder results.

  4. Optional. identifier still rejects :. Still open. Lines 314-322 still omit it.

  5. Optional. Lines 429-430 still say remote plan and apply need no wire change. Still open.

  6. Optional. Namespace-pattern policy still disagrees with actor views. Still open. Lines 335-337 and the rollout item "Namespace-scoped per-type policy rules" (line 772) remain. rfc: granular permissioning #951 at 7f3d601e still says that sugar contradicts D8.

  7. Optional. The banner path still cites only the directory arm. Still open. Line 158.

  8. Optional. The pin list is still shorter than rfc: polymorphic types with interfaces, unions and polymorphic edges #884. Still open. Lines 570-572 omit rebindings, alternation members, and the result-descriptor refusal. rfc: polymorphic types with interfaces, unions and polymorphic edges #884 at a4df45c9 lines 725-728 still has that list.

The earlier claim "execution sees only type ids" stays corrected. Lines 486-489 still say the IR carries canonical names, and the planner and the engine find tables by node:{type_name}. The new link does not restore the old claim. It defers the id-carrying design to #1079. It does not implement that design, and this RFC does not depend on it.

New finding

Optional. Narrow the phase label and the cache sentence (lines 509-512).

"#1079 tracks it, as phase 0 of Polymorphic types." #884 at a4df45c9 puts #1079 inside phase 0 as one bullet (lines 1162-1166). Phase 0 also validates interface properties, refuses inherited-key loss, refuses cross-kind name reuse, and changes three lowering sites. Please say "one item of phase 0".

"derived state keyed by id, such as the graph-index topology cache, no longer rebuilds after a rename."

Today that cache is not keyed only by id. GraphIndexTableState stores identity and also table_key and endpoints (crates/omnigraph/src/runtime_cache.rs). TableStamp stores edge_name, from_type, and to_type (crates/omnigraph/src/graph_index/persist.rs). A pure rename publishes RenameTable and keeps the dataset version (crates/omnigraph/src/db/omnigraph/schema_apply.rs, the rename publication loop). The cache misses because the name fields change. The built index also keys dictionaries and adjacency by those names (GraphIndex::type_index, the CSR maps). The comment on endpoints says a name change must invalidate the entry, or a later lookup misses the type index.

#1079 says caches will use the id. It does not say this artifact drops its name fields, and it does not say the built index is relabeled. "No longer rebuilds" is not a decision in that issue. Please say that a pure rename misses today because the stamp and the cache key contain names, and that #1079 is the place that would have to key those structures by id.

The sentence "Namespaces do not depend on it" matches both drafts. Please keep it.

Does #1079 match the code at 0a62bb7b?

These claims match the code at main 0a62bb7b:

  • IROp::NodeScan has type_name: String. IROp::Expand has src_type: String and dst_type: String (crates/omnigraph-compiler/src/ir/mod.rs).
  • The planner and the engine build node:{name} and edge:{name} keys. engine/scan.rs and engine/operators/scan/input.rs build node keys. engine/graph.rs builds edge keys. crates/omnigraph-planner/src/source.rs builds both.
  • Snapshot.entries is HashMap<String, DatasetEntry> (crates/omnigraph-catalog/src/lib.rs).
  • TypeRefIR { type_id, type_name } already exists on SchemaIR (catalog/schema_ir.rs).
  • Catalog::node_type_id, edge_type_id, and interface_type_id scan the vectors by name. New type ids for every kind come from one Allocator in resolve. Property ids and incarnation ids use that same allocator.
  • The graph-index stamp and cache key include type and edge names, as above.
  • filter_digest hashes scope.type_names (crates/omnigraph/src/changes/token.rs). A stored cursor does not change on rename. A client that changes the filter string gets a new digest.
  • rfc: polymorphic types with interfaces, unions and polymorphic edges #884 records prototype findings P1, P2, P4, and P6. That part cites the draft, not current main.

This claim does not match the code:

  • "Adding type_id beside type_name keeps the frozen v1 engine compiling, because it matches IR nodes with ..." Some matches use ... These do not. collect_pipeline_columns in crates/omnigraph-reference-engine/src/query.rs lists every field and says a new field must be decided there. The execute match in the same file lists type_name and the expand fields with no ... collect_node_bindings in projection.rs lists every field with no ... Adding a field fails those matches. The crate sources are byte-pinned by frozen.rs, so an edit also updates the pin. Please correct that sentence on rfc: resolve type references to StableTypeId below the compiler #1079. This RFC does not repeat it.

Alignment

Tradeoffs and long-term cost

The link is the right shape. One issue owns the IR change. This RFC does not grow a second plan for it. The cost of a wide benefit sentence is small but real. An implementer can treat the topology cache as already id-keyed, drop the name fields from the key only, and then serve a name-keyed index after a rename. The comment on endpoints exists to prevent that.

The two open blocking items are the liability that remains. The resolver and the pin are still described as if they catch the same case. The probable-move rule still misses a case-folded edge.

After five more changes of this kind, one string and one rename path still hold. The weak point is still the two safety paragraphs, not the new link.

Regression proof

This PR changes documents only. It does not change rows, counts, column types, or errors. GQT cannot express the new sentences. They are claims about current source and about a future issue. I did not add a temporary .gqt case. I did not run the GQT corpus.

Validation

  • Reviewed head: 9c360b5a47723cd0dd8520dfa6b8145cdc9ac7f8. Parent: 7565f5ffb19d3150ae3c1857bf43b7a28592f492. Base: 0a62bb7b2aa5b99b012f413807c216277f7f9596. The compare is one file and four added lines.
  • python3 scripts/check-docs.py in a clean worktree at that head: Documentation OK (342 Markdown files checked).
  • typos 1.50.1 on the RFC and docs/rfcs/README.md: no findings.
  • Code checks are reads of main 0a62bb7b, which is the tree the sentences describe. I did not run cargo test.
  • Lance table schema page (https://lance.org/format/table/schema/), read for the unchanged Lance section only. A field id survives a rename. The name does not. The delta adds no Lance claim. I did not re-read the other Lance domains.

Limits

Inline comment

docs/rfcs/2026-10-15-schema-namespaces-and-multi-file-schemas.md lines 509-512

Optional. Narrow the phase label and the cache sentence.

#1079 is one bullet of phase 0 in #884 at a4df45c9 (lines 1162-1166), not the whole phase.

The graph-index cache is not keyed only by id today. A pure rename keeps the dataset version and changes table_key, endpoints, and the artifact stamp names. The built index is also name-keyed. #1079 does not say those structures drop their names, so "no longer rebuilds" is not yet a decision. Please say the miss is the name fields, and that #1079 is where an id key would be decided.

Also, please correct #1079's sentence that adding type_id beside type_name keeps the frozen v1 engine compiling. collect_pipeline_columns, the execute match, and collect_node_bindings list the fields and do not use ...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant