Repository navigation
Conversation
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 |
There was a problem hiding this comment.
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:
- Set
discussion:to schema: native imports for composing .pg files #838, or to the issue that maintainers choose. - Add a short disposition: this RFC replaces the
importdesign of schema: native imports for composing .pg files #838. - 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
.pgfile that you can check alone (an editor or a one-filelintcannot resolvecore::Namedincrm.pgwithout the config); sharing a core file across clusters, becausecheck_config_pathrefuses... - 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.
| 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. |
There was a problem hiding this comment.
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 }andIROp::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}")incrates/omnigraph/src/engine/scan.rs:153,engine/operators/scan/input.rs:252andcrates/omnigraph-planner/src/source.rs:367;format!("edge:{edge_type}")inengine/graph.rs:637. - No
StableTypeIdortype_idappears inomnigraph-compiler/src/query/,omnigraph-compiler/src/ir/oromnigraph-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:
- One snapshot per query (invariant 3), so the name lookup and the catalog agree.
- Stored queries are typechecked again at
cluster plan(validate_query_sourceinomnigraph-cluster/src/config.rs), at apply (deployment/execution.rs:791) and at server load (omnigraph-server/src/queries.rs:265). - The resolver gives an exact
(kind, name)match priority over a hint (resolveincatalog/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 newnode Alphain one plan givesaccepted node 'Alpha' is consumed by more than one desired declaration. - 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.
| - Bundles keep carrying one composed source, so remote `cluster plan` and | ||
| `cluster apply` need no wire change. |
There was a problem hiding this comment.
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
PlanRequestandDeploymentRequest(crates/omnigraph-server/src/deployment.rs:100-112, bothdeny_unknown_fields); - into cluster storage, as
deployment/{digest}.json(write_deployment_bundleinstore.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 aPathBuf. The older server refuses withdeployment_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.
| - **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. |
There was a problem hiding this comment.
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:
- Own the decision in this RFC. Give the refusal code (reuse OG-DS-102/103?), the explicit way to drop a populated type on purpose, and a Compatibility row. This changes a supported behavior for every graph, with or without namespaces.
- Or record the gate. Put docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984 in
blocked_on, and get a maintainer decision on docs(schema): OG-DS-102/103/104 are listed as refusals, but populated drops plan as supported #984 in this direction first.
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): |
There was a problem hiding this comment.
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. Addnamespacesto that test, so the claim at lines 357-359 stays pinned. (I checked the mechanism: the envelope gate inschema_state.rs:45-56runs 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. .gqtcases undercrates/omnigraph-gqt/cases/for the ambiguity and unknown-namespace errors. They show in errors, and AGENTS.md makes.gqtthe default owner for query behavior.- A row that proves a property named
namespaceoruse, an edgeUseand the aliasas usekeep 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.
| - 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. |
There was a problem hiding this comment.
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:
- 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. schema: schema/andschema: [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).
| - Type names stay Cedar entity ids (strings) and never become Cedar entity | ||
| types, because Cedar already uses `::` for those (`Omnigraph::…`). |
There was a problem hiding this comment.
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,ServerandClusterentities (crates/omnigraph-policy/src/lib.rs:635-644,entity_uidat 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
.gqtype_namealready admits the@prefix (type_name = @{ "@"? ~ ASCII_ALPHA_UPPER ~ … },query.pest:260, from feat(query): expose engine catalogue as built-in types #1008). Only the.pgrule is as quoted.
| 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. |
There was a problem hiding this comment.
Optional: two grammar details.
type_refalready exists in both grammars as the property-type rule (schema.pest:38type_ref = { core_type ~ "?"? },query.pest:138for parameter types). Please give the shared rule a new name, for examplequalified_type_name, so the two RFCs do not collide with it.- 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" $bor$i crm::"in" $b, and say whetherin/and/or/not/is/nullafter::stay reserved.
|
|
||
| ```pg | ||
| namespace crm { | ||
| node Contact @rename_from("Contact") { … } // was the root Contact |
There was a problem hiding this comment.
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.
| - **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. |
There was a problem hiding this comment.
Optional: state the real size limit, and the one rule that differs from queries:.
- Each file read has the
MAX_CONFIG_SOURCE_BYTEScap (1 MiB). Butvalidate_bundlealso 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 warningquery_dir_empty(config.rs:242-250). Please write "with one difference".
Review: RFC "Schema namespaces and multi-file schemas" at
|
…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.
Review of the RFC at
|
Review of the four-line delta at
|
What & why
This adds a draft RFC, Schema namespaces and multi-file schemas. It covers two things:
.pgfiles. They are composed into one canonical source before hashing, so no storage, IR, ledger or wire format changes.namespace crm { … }gives the canonical namecrm::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 existingRenameType/RenameTablepath.Today a schema is one file everywhere (
parse_schema(&str),GraphConfig.schema: PathBuf), and type and edge names are global.Backing issue / RFC
Checklist
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 findingsNotes for reviewers
Code facts at
fe69c039that shape the designsha256(source bytes): the contractsource_hash, the clusterschema.<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.table_keyalias in__manifestrows and immutable__historyextents. Renames already rewrite it metadata-only, so the chosen spelling (node:crm::Contact) is permanent history, not a format change.namespacesfeature name keeps older binaries refusing cleanly instead.:. These are the CLIembed --selectselector and the GQTexpect planandexpect shapeparsers. They need fixing with the namespace phase.Dependencies
@rename_fromsilently plans as drop plus add.@rename_from.Composition with Polymorphic types (#884)
The RFC has a section on this. In short:
implements, type tests and typed endpoints;StableTypeIdand@type_id;implementsconfers 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, Terraformmovedblocks.Unresolved questions
use;