pglpg 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 64d4bfb95eeacd8297e72c8dc02b95bcda6354e5c3c157655483c13d78e9b9a8
4
- data.tar.gz: c75cbedcd42f7fe07265f8aecfea1e78adc5e44f3651587b069955ea6c47f254
3
+ metadata.gz: 61a05d0f91dc037aefaaf82fa99143eae9a3c2742dd2308adcffa0d364b2f6fc
4
+ data.tar.gz: 1286500cadead4a337382e5bba4a5ec60bc861bcec88bfb04c07856aadaa4cf3
5
5
  SHA512:
6
- metadata.gz: 1b0d31c4236eac2f7b3cb22b65d7650c1a01d3687ea5505b44e64b540e54f9d6e880344e2bbc1f90a6bb1512dcfdaad5c06ea0dc58ea621f768e82c50926e513
7
- data.tar.gz: a8bcb6da10e7779df9409d7dc5923a0f172177d4d2acb8f695a347adf89019bc1d04b4a5187b8c85560b7043b171bfc0bc464c1256189c4ae43db59d5c4f0a5d
6
+ metadata.gz: c76e6ed407cbb7ef56cc9e79999cad98c91d67e7f46b08b2aba548c197cd52409b6d70ae83b029e032c9c3dcda1594a3825241727a0da45c91a553c456bead57
7
+ data.tar.gz: 5c4c89d4af4a6f57988a734cc0d154b9b9c96a8dea5a15c51b7ab3802078f27d5468e7f42c6e98a6698befca2cb58c9ce7e5c9cc44e0482470d157951100a804
data/CHANGELOG.md CHANGED
@@ -1,5 +1,254 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 (2026-11-03)
4
+
5
+ ### Breaking
6
+
7
+ - Temporal-capable reads require `graph.for(viewer:, as_of: Date)` or
8
+ `as_of: :any`; omitted/nil mode permits only node-only and statically
9
+ timeless-only reads. Generic edge Ref lookups in graphs with temporal types
10
+ require a mode even for missing edges. Date contexts refuse all data writes
11
+ with `ReadOnlyContext`, including model no-op saves and trusted-writer access.
12
+ - Temporal inserts and every temporal upsert require explicit `valid_from:`
13
+ presence (nil is unbounded); omitted update bounds preserve stored values.
14
+ On upsert, an omitted upper bound is nil on insert and preserved on conflict.
15
+ - Enabling temporal types on existing populated tables requires
16
+ `storage.enable_temporal!(types:, backfill: nil)` in an explicit migration.
17
+ Reserved `valid_from`/`valid_to` names must first be removed from application
18
+ declarations. Incompatible legacy columns require an application migration;
19
+ timestamp values are never silently cast to dates. The helper locks the
20
+ table and atomically installs metadata/checks plus an optional backfill.
21
+ - Complete-key lookups validate literal component values: nil, arrays and
22
+ query conditions are refused before SQL, including on existing single/scoped
23
+ keys. Use `match` for structured property filters.
24
+ - Adopting composite/edge keys or endpoint/property uniqueness requires
25
+ auditing and backfilling all required components, resolving duplicates and
26
+ running `storage.sync_constraints!` in the same application transaction.
27
+ New caller-chosen key forms require the trusted writer; unique components
28
+ cannot be changed and keyed edge upserts cannot rewire endpoints.
29
+ - Opting a type into `open: true` rejects duplicate String/Symbol aliases for
30
+ declared fields as well as extras. Closed normalization is unchanged.
31
+ Openness inherits from any open parent; local false does not close it.
32
+ Closing/promoting declarations needs explicit data audit/backfill.
33
+
34
+ ### Added
35
+
36
+ - Explicit `open: true` node and edge properties (ADR-0015), with openness
37
+ inherited from any open node parent. Closed remains the default. Declared
38
+ property/key checks, labels, endpoints, visibility and temporal rules remain.
39
+ - Extra properties accept strict JSON-native scalars and flat homogeneous
40
+ scalar arrays; nil means absent/removal. Invalid names, duplicate aliases,
41
+ metadata/application-column collisions and unsupported values are refused
42
+ before the data statement. Open-type alias rejection includes declared
43
+ fields; closed normalization is unchanged. Extra decoded JSON values,
44
+ including historical map keys, are deeply frozen.
45
+ - Model `extra_properties` remains read-only and declared saves preserve
46
+ extras. Query/filter/order/pluck still require declared properties. Openness
47
+ adds no DDL; closing/promoting types requires explicit application auditing
48
+ and backfill. Optional `pglpg_open_types` shared leak fixtures cover extras.
49
+
50
+ - Composite node keys and edge property keys (ADR-0014): `key :a, :b` on
51
+ required string/integer/boolean/enum properties, with inherited node-key
52
+ domains and exact edge-type domains. General tuples and single non-string
53
+ keys require trusted insertion; legacy single-string generation and scoped
54
+ node occupancy grants retain their behavior. All components are immutable.
55
+ Unsupported edge scope declarations raise `SchemaError` explicitly.
56
+ - `Context#find_edge(type, **key)` looks up a full edge property key through
57
+ current visibility on the edge and both endpoints, with the context's date.
58
+ Complete-key node/edge lookups validate literal values before SQL.
59
+ - `unique: [:p, :q]` enforces a required endpoint/property tuple per edge type.
60
+ Trusted keyed edge upsert accepts `key:` and requires unchanged endpoints;
61
+ without a property key, upsert uses endpoint/property uniqueness. Constraints
62
+ are cumulative and span all dates. Uniqueness properties cannot be updated.
63
+ - Composite/non-string model keys are required attributes, created through
64
+ `save(writer:)`; generated keys remain unassignable. Composite key readers
65
+ return frozen maps; legacy node scalar/scoped key readers stay scalar.
66
+ Adoption requires application backfill/deduplication and transactional
67
+ `sync_constraints!`; no automatic data migration is performed.
68
+
69
+ - Context inspection shows an explicitly selected Date or `:any` read mode.
70
+ - Opt-in `temporal: true` edge types (ADR-0013): exact Date metadata,
71
+ half-open `[valid_from, valid_to)` intervals, nil unbounded, finite dates
72
+ 0001-01-01 through 9999-12-31. DateTime, Time and String are rejected.
73
+ - Context-wide date eligibility on direct edge reads, every pattern and
74
+ reachability hop, models, lazy associations and eager batches, combined with
75
+ current visibility before counts, pagination, limits and completeness.
76
+ Timeless edges remain eligible; `:any` ignores only validity. This is valid
77
+ time on the stored graph, not historical values or historical permissions.
78
+ - `TemporalModeRequired` and `TemporalMigrationRequired` errors. Generated
79
+ CHECK digests track explicit temporal enablement; changed/absent or
80
+ unvalidated checks on populated temporal types require acknowledgement.
81
+ Omitted backfill preserves bounds, and disabling types retains columns.
82
+ - Edge values expose `temporal?`, `valid_from`, `valid_to`; temporal models
83
+ have strict metadata attributes, explicit nil assignment tracking and bound
84
+ pluck support. Lifetime pair uniqueness remains unchanged: gaps require
85
+ separate non-pair-unique episode Refs; overlaps are allowed.
86
+ - Optional `pglpg_temporal` shared leak-fixture mode, and `bench/temporal.rb`
87
+ for same-fixture reference comparisons and temporal index experiments.
88
+
89
+ ### Security
90
+
91
+ - Schema constraint failures use a generic ValidationError without a database
92
+ cause or stored-row detail. Temporal upsert argument validation is independent
93
+ of visible, hidden or absent pair occupancy.
94
+
95
+ ## 0.2.0 (2026-10-30)
96
+
97
+ ### Breaking
98
+
99
+ - Symmetric edge types use `both`; `out` and `in` reject them, and `both`
100
+ rejects directed types. After adding a symmetric type, run
101
+ `Kg.storage.sync_constraints!` to install its canonical-endpoint constraint.
102
+ - Adopting scoped keys requires a required string scope property, both scope
103
+ and key in lookups and trusted upserts, and a transactional backfill with
104
+ `sync_constraints!` on the same connection. Interactive caller-chosen scoped
105
+ keys require an explicit `key_scope_allowed` occupancy grant. Existing
106
+ unscoped key declarations keep their contract.
107
+
108
+ ### Security
109
+
110
+ - Database constraint failures on refused writes no longer expose the original
111
+ database exception through `Exception#cause`, including unscoped writes.
112
+
113
+ ### Added
114
+
115
+ - Scoped keys (ADR-0012): `key :uid, scope: :namespace` enforces a required
116
+ string namespace/key pair across the declaring type and subtypes. Interactive
117
+ caller-chosen keys require an explicit `key_scope_allowed` grant to learn
118
+ occupancy within that graph/domain/scope, including hidden collisions. The
119
+ grant gives no row access and has no SQL-time revocation guarantee. Trusted
120
+ upserts and `find_node` require both parts; scope and key remain immutable.
121
+ Existing schemas require a transactional backfill and constraint migration.
122
+
123
+ - Add `with_associations` to model relations for batched, visibility-aware eager loading (ADR-0011). Preloaded associations are materialized reads and can become stale; reload, save, or destroy clears that instance's cache. Each batch uses a separate database snapshot and raises `TooManyRows` when its distinct owner–target pairs exceed `max_rows`.
124
+
125
+ - Model layer, reads (ADR-0010; `require "pglpg/model"`):
126
+ - `Pglpg::Model::Node` and `Pglpg::Model::Edge` are ActiveModel classes
127
+ bound to a graph and a schema type with `graph Kg, type: :Person`. Their
128
+ attributes are the type's properties and the graph's application
129
+ columns. Assignment is strict: no form coercion.
130
+ - Declaring a model rejects a second model for the same type (a class of
131
+ the same name replaces it, for code reloading), a property named like an
132
+ application column or a model method, and a model extending another
133
+ model off the schema's inheritance.
134
+ - `Model.in(ctx)` returns a `Pglpg::Model::Relation` with `where`, `order`,
135
+ `limit`, `offset`, `count`, `exists?`, `first` and `find_by`. It compiles
136
+ to a `Pglpg::Query`, so visibility, ordering, `max_rows` and timeouts
137
+ apply as there. Edge relations return each symmetric edge once.
138
+ - `Relation#find(ref)` takes a `Ref` or its String form and raises
139
+ `Pglpg::Model::RecordNotFound` for a missing, hidden or wrong-type
140
+ element alike.
141
+ - A node loads as the model registered for its concrete type, else as the
142
+ queried model; `schema_type` names the stored type and
143
+ `extra_properties` holds properties the model doesn't declare.
144
+ - The shared leak suite observes models, relations and `find`.
145
+ - Model layer, writes (ADR-0010):
146
+ - `Relation#new` / `#build`, `#create` and `#create!` build records bound
147
+ to the relation's context. Edge records take `source:` and `target:` (a
148
+ node Ref or model); they can't change once stored.
149
+ - `save` / `save!` validate, then insert a new record or update only its
150
+ changed attributes through the context's guarded write API. `save`
151
+ returns false on validation errors; `save!` raises
152
+ `Pglpg::Model::RecordInvalid`. Refusals, timeouts and database errors
153
+ raise, and changes survive any failure. Without changes, no statement
154
+ runs.
155
+ - Schema validations: property values, required properties and required
156
+ columns. Keys are generated: an assigned key is an error on create, a
157
+ changed key on update. Abstract types can't be created.
158
+ - `save(writer:)` and `destroy(writer:)` accept only the record's own
159
+ context's trusted writer (`ArgumentError` otherwise), for trusted-only
160
+ writes such as pair-unique edges; it never permits assigning a key.
161
+ - `destroy` deletes an edge, or a node through the trusted writer only;
162
+ `destroyed?`.
163
+ - `reload` replaces attributes, timestamps and the dirty baseline, and
164
+ raises `RecordNotFound` without changing the record when the element
165
+ is missing or hidden.
166
+ - After a create, generated keys and timestamps are unknown until
167
+ `reload`. A caller's rollback is not tracked.
168
+ - Model layer, associations (ADR-0010 §5):
169
+ - `has_many name, :out | :in | :both, edge:, label: nil, model: nil` on a
170
+ node model defines a method returning a `Pglpg::Model::AssociationRelation`.
171
+ `:both` is for symmetric types, `:out` / `:in` for directed ones. The
172
+ target label defaults to the edge type's other endpoint and the model to
173
+ the one registered for it. Declaring checks the direction, that the
174
+ owner can be an endpoint, and name clashes; associations are inherited.
175
+ - A target query returns each visible target once: duplicates are removed
176
+ before `order`, `limit`, `offset`, `count` and `exists?`. `rel_where`
177
+ filters on edge properties ("at least one matching visible edge");
178
+ `rel_order` raises there.
179
+ - `with_rel` returns a `Pglpg::Model::BindingRelation`: one
180
+ `[target, edge]` pair per matching edge, counted and paginated as pairs,
181
+ with `rel_order`. It must come before `order`, `limit` or `offset`.
182
+ `each_with_rel` enumerates it. Edges load as the registered edge model.
183
+ - Associations need a saved owner, start from it (so its visibility
184
+ applies) and can't build records yet.
185
+ - The shared leak suite observes every association of every visible owner:
186
+ targets, counts, `exists?`, `first`, pagination, `rel_where` and pairs.
187
+ - Model layer, `pluck` (ADR-0010 §5):
188
+ - `Relation#pluck(*names)` returns attribute values (or `:id`, `:ref`) per
189
+ element, in the relation's order: a value for one name, an Array for
190
+ several. Elements are chosen first and projected afterwards, so two
191
+ targets with equal values give two values.
192
+ - `BindingRelation#pluck(*names, rel: [])` gives one entry per pair:
193
+ the target's names, then the edge's `rel` names.
194
+ - Unknown names raise `ArgumentError` before any query runs; `max_rows`
195
+ applies as for `to_a`.
196
+ - The shared leak suite also observes `pluck` on relations, associations
197
+ and pairs.
198
+ - `Context#reachable(from:, via:, max_depth:, max_nodes:, direction: :out, deadline: nil)`
199
+ returns a `Pglpg::Reachability`: visible nodes with their minimum depth,
200
+ ordered by depth then id, plus `complete` and `stopped_at_depth`.
201
+ - Runs as one recursive CTE that computes one BFS level per step (ADR-0009),
202
+ so it sees one snapshot at any isolation level.
203
+ - The start node is never returned; a hidden start and a missing one give
204
+ the same empty, complete result.
205
+ - `direction:` follows the `match` rule from ADR-0008, and a `via` list may
206
+ not mix directed and symmetric types.
207
+ - `max_nodes` may not exceed `max_rows`. `deadline` (seconds) can only
208
+ shorten the configured `statement_timeout`.
209
+ - The shared leak suite observes `reachable` from every visible node.
210
+ - `Context#read` takes a `timeout:` in milliseconds (internal), and every read
211
+ now runs in a savepoint, also with `statement_timeout: nil`. A timeout set
212
+ by the session itself no longer leaves the caller's transaction aborted.
213
+ - The shared leak suite keeps its `reachable` budgets within `max_rows`.
214
+ - The benchmark is rebuilt for CI (task 36367): seeded, versioned 1m,
215
+ hub-heavy and 10m fixtures with a manifest. Its cells cover viewers L2 and
216
+ L4, with starts each viewer can see, and include `reachable`. It gates
217
+ per-cell bounds in `bench/bounds.yml`, and compares buffers and latency
218
+ with a baseline revision. Buffers come from JSON plans and fail closed, and
219
+ a self-test proves that the gate fails. `bench/run.rb` is replaced by
220
+ `bench/measure.rb` and `bench/gate.rb`. A new `bench.yml` workflow runs the
221
+ full fixtures. The CI benchmark step no longer hides a failure behind
222
+ `tee`.
223
+ - Symmetric edge types (ADR-0008): `edge name, from:, to:, symmetric: true`,
224
+ with `allow_self_loops: true` to permit self-loops (off by default).
225
+ - Each edge is stored once with `source_id < target_id` (`<=` with self-loops),
226
+ enforced by a generated `CHECK`; `sync_constraints!` adds it. Writes accept
227
+ the endpoints in either order, and `WriteRequest#from` / `#to` carry the
228
+ canonical order. `unique: :pair` covers the unordered pair.
229
+ - One endpoint carries `from` and the other `to`, in either order.
230
+ - `Query#both` follows a symmetric edge from either end (GQL `~[ ]~`). With
231
+ no endpoint fixed, each edge binds once per orientation (once for a
232
+ self-loop); use `return(:edge).distinct` to list edges once.
233
+ - **Breaking:** `out` and `in` raise `ArgumentError` for symmetric types, and
234
+ `both` raises it for directed types. `symmetric:` no longer raises
235
+ `SchemaError`.
236
+ - `Edge#symmetric?` and `Edge#other(ref)`.
237
+ - The shared leak suite adds symmetric cases: hidden bridges and hidden
238
+ nodes stored at either end of an edge.
239
+ - The benchmark adds symmetric 1- and 2-hop shapes and checks the buffers of
240
+ the symmetric hub query.
241
+
242
+ ### Fixed
243
+
244
+ - Storage DDL is atomic: `sync_constraints!` and the entire `create!` operation
245
+ use a transaction on their supplied connection, or a savepoint in an existing
246
+ ActiveRecord transaction. A failed call restores its DDL and leaves a healthy
247
+ caller transaction usable. Outside a transaction, each call commits its own
248
+ transaction. Application backfills still need an explicit outer transaction
249
+ on the same connection to be rolled back with a failed schema transition.
250
+
251
+
3
252
  ## 0.1.0 (2026-10-28)
4
253
 
5
254
  First version, following the accepted 0.1 baseline (knowen.md bb topic 145).
data/README.md CHANGED
@@ -8,10 +8,65 @@ ordinary tables, written inside your own transactions. Every query applies
8
8
  your visibility policy to every node and edge on every hop, so hidden records
9
9
  never act as bridges and never show up in counts or limits.
10
10
 
11
- Status: **0.1, pre-release.** The design and its decisions live in the
11
+ Status: **0.3.0 release candidate; not yet published.** The latest published
12
+ version is 0.2.0. This candidate includes temporal edges, composite/edge keys
13
+ and explicit open properties. The design and its decisions live in the
12
14
  [pglpg.md](https://knowen.md/nodes/36325) knowledge base. Requires Ruby ≥ 4.0,
13
15
  ActiveRecord ≥ 8.1 and PostgreSQL ≥ 18: the versions CI tests.
14
16
 
17
+ ## Install
18
+
19
+ After 0.3.0 publication:
20
+
21
+ ```ruby
22
+ gem "pglpg", "~> 0.3.0"
23
+ ```
24
+
25
+ Before 1.0, minor releases may break compatibility. Read the
26
+ [0.3.0 upgrade notes](CHANGELOG.md) and the migration steps below before
27
+ upgrading from 0.2. Applications still on 0.1 must also apply the 0.2 notes.
28
+
29
+ ## Upgrade from 0.2
30
+
31
+ - Choose `as_of: Date` for dated reads or `as_of: :any` for all dates.
32
+ Omitted mode permits node-only/timeless-only reads; generic edge Ref lookup
33
+ needs a mode whenever the graph declares a temporal edge type. Dated
34
+ contexts are read-only, including model saves and trusted-writer access.
35
+ - Temporal inserts and every upsert require explicit `valid_from:` (nil means
36
+ unbounded). Update omissions preserve bounds; an upsert's omitted upper
37
+ bound is nil on insert and preserved on conflict.
38
+ - Before enabling temporal traffic on existing tables, finish an explicit
39
+ `enable_temporal!` migration. Remove conflicting application declarations,
40
+ audit legacy timestamps and `valid_until` boundaries, and convert them in
41
+ application migrations without silent truncation. Keep reads/writes disabled
42
+ during a deploy-then-migrate window. Retained temporal columns make every
43
+ constraint sync take an edge-table `ACCESS EXCLUSIVE` lock until outer commit;
44
+ schedule short migration transactions.
45
+ - Adopt new key/uniqueness declarations with an explicit same-connection
46
+ transaction: backfill required components, resolve duplicates, then call
47
+ `sync_constraints!`. Composite/non-string keys and endpoint-unique edges
48
+ require trusted creation. Unique properties are immutable; keyed edge upsert
49
+ cannot rewire endpoints. Legacy single-string generation/scoped grants and
50
+ lifetime pair uniqueness retain their rules. Complete-key lookups take
51
+ literal values; use `match` for condition/array filters.
52
+ - `open: true` is optional and changes no DDL. It rejects duplicate aliases across all
53
+ submitted properties, reserves structural/validity names even on other
54
+ element kinds/timeless graphs, and inherits from any open parent. Audit data
55
+ before closing a type or promoting extras to declarations. Queries and model
56
+ attributes still require declared properties; low-level writes and model
57
+ reload manage extras.
58
+
59
+ Existing timeless, closed schemas with unchanged keys need no migration solely
60
+ for the version bump. Rerun your real-policy leak suite and label-drift checks
61
+ when adopting this minor release.
62
+
63
+ This candidate implements a documented standards subset: key components are
64
+ string/integer/boolean/enum, open extras are scalars/flat homogeneous lists,
65
+ and temporal validity is one date interval with current authorization. It does
66
+ not add alternate/overlap keys, open labels, dynamic query/model attributes,
67
+ chained model associations or a GQL parser. Production-like acceptance remains
68
+ separate pre-1.0 work; ordinary checks do not establish managed-plan capacity.
69
+
15
70
  ## Define a graph
16
71
 
17
72
  ```ruby
@@ -36,12 +91,17 @@ Kg = Pglpg::Graph.define(:kg) do
36
91
  property :role, :string
37
92
  end
38
93
  edge :KNOWS, from: :Person, to: :Person
94
+ edge :FRIEND, from: :Person, to: :Person, symmetric: true # undirected
39
95
  end
40
96
  ```
41
97
 
98
+ A `symmetric: true` edge type is undirected: each edge is stored once, smaller
99
+ node id first, and writes accept the endpoints in either order. Self-loops are
100
+ refused unless the type sets `allow_self_loops: true`.
101
+
42
102
  Property types: `string integer float decimal boolean date datetime enum list`
43
- (`enum` takes `values:`, `list` takes `of:`). Types are closed: undeclared
44
- properties are rejected. A nil value means the property is absent.
103
+ (`enum` takes `values:`, `list` takes `of:`). Types are closed by default: undeclared
104
+ properties are rejected unless the type opts into open properties. A nil value means the property is absent.
45
105
 
46
106
  In a migration:
47
107
 
@@ -118,6 +178,10 @@ rows.first[:q] # => #<Pglpg::Node ...>
118
178
  g.find(ref) # visible node or edge, else nil
119
179
  g.find_node(:Person, uid: "PER-12")
120
180
  query.count; query.exists?; query.first; query.distinct; query.explain(analyze: true)
181
+
182
+ # Symmetric edge types use both (out and in refuse them). Each edge binds once
183
+ # per orientation, so list edges once with return(:f).distinct.
184
+ g.match.node(:p, ref: ann).both(:FRIEND, :q, :Person, edge: :f)
121
185
  ```
122
186
 
123
187
  Conditions are structured only: a value (equality), an array (one of), nil
@@ -125,6 +189,208 @@ Conditions are structured only: a value (equality), an array (one of), nil
125
189
  raise `Pglpg::TooManyRows` beyond `max_rows` (default 1000); reads run under
126
190
  `statement_timeout` (default 5 s) and raise `Pglpg::QueryTimeout`.
127
191
 
192
+ ## Reachability
193
+
194
+ ```ruby
195
+ r = g.reachable(from: ann, via: %i[KNOWS], max_depth: 3, max_nodes: 500, deadline: 2.0)
196
+ r.nodes # => [[ref, 1], [ref, 1], [ref, 2], …] by depth, then id
197
+ r.complete? # false when more visible nodes are reachable within max_depth
198
+ r.stopped_at_depth # depth of the last returned node when incomplete, else nil
199
+
200
+ g.reachable(from: ann, via: %i[KNOWS], direction: :in, max_depth: 2, max_nodes: 100)
201
+ g.reachable(from: ann, via: %i[FRIEND], direction: :both, max_depth: 2, max_nodes: 100)
202
+ ```
203
+
204
+ Each visible node comes once, at its minimum depth; the start node never does.
205
+ Hidden nodes and edges are never traversed, and a hidden or missing start gives
206
+ an empty, complete result. `via` lists edge types that may be followed at any
207
+ hop. `direction:` follows the same rule as `match`: `:both` takes only
208
+ symmetric types, `:out` (the default) and `:in` only directed ones.
209
+ `max_nodes` may not exceed `max_rows`.
210
+
211
+ The traversal is one SQL statement (ADR-0009), so it sees one snapshot at any
212
+ isolation level. `deadline` is in seconds and can only shorten the configured
213
+ `statement_timeout`; with no timeout configured it applies alone. Running out
214
+ of time raises `Pglpg::QueryTimeout` and leaves your transaction usable.
215
+
216
+ ## Models and eager loading
217
+
218
+ `require "pglpg/model"` adds optional ActiveModel records bound to a graph and
219
+ schema type. Context-bound relations support reads, guarded saves, associations,
220
+ `pluck` and visibility-aware batched eager loading with `with_associations`.
221
+ See the [API contract](https://knowen.md/nodes/36329) for examples, cache lifetime
222
+ and row limits.
223
+
224
+ ## Scoped keys
225
+
226
+ A type can declare `key :uid, scope: :namespace` when both properties are
227
+ required strings. Uniqueness covers the scope/key pair across the type and its
228
+ subtypes. `find_node` and trusted upserts require both values. An interactive
229
+ caller-chosen key needs an explicit `key_scope_allowed` grant: it permits
230
+ learning occupancy in that scope, including hidden collisions, without granting
231
+ row access. Scope and key are immutable. Existing data needs a transactional
232
+ backfill and constraint migration; see the
233
+ [API contract](https://knowen.md/nodes/36329).
234
+
235
+ ## Composite and edge keys (0.3.0 candidate)
236
+
237
+ Keys can use a tuple of required string, integer, boolean or enum properties:
238
+
239
+ ```ruby
240
+ node :Document do
241
+ property :catalog, :string, required: true
242
+ property :number, :integer, required: true
243
+ key :catalog, :number
244
+ end
245
+ edge :CITES, from: :Document, to: :Document do
246
+ property :source, :string, required: true
247
+ property :external_id, :string, required: true
248
+ key :source, :external_id
249
+ end
250
+ ```
251
+
252
+ General tuples and single non-string keys require a trusted writer on insert.
253
+ Single string keys retain automatic generation; existing scoped node keys
254
+ retain their explicit occupancy grant. Edge keys are unique within the edge
255
+ type, independently of endpoints. A keyed edge upsert requires the full key
256
+ and the original endpoints; it cannot rewire a relationship.
257
+
258
+ ```ruby
259
+ w = ctx.trusted_writer
260
+ ref = w.upsert_node(:Document, key: { catalog: "main", number: 7 })
261
+ ctx.find_node(:Document, catalog: "main", number: 7)
262
+ w.upsert_edge(:CITES, from: ref, to: other,
263
+ key: { source: "archive", external_id: "citation-1" })
264
+ ctx.find_edge(:CITES, source: "archive", external_id: "citation-1")
265
+ ```
266
+
267
+ `unique: [:role]` on an edge type adds the required role to its endpoint-pair
268
+ uniqueness tuple. It allows different roles between the same endpoints and
269
+ requires trusted insertion/upsert. A declared `key` and `unique:` can coexist;
270
+ keyed upsert uses the key, and both constraints still apply. All key and
271
+ uniqueness properties are immutable, and uniqueness spans all validity dates.
272
+ Symmetric types use the canonical unordered endpoint pair.
273
+
274
+ Composite model attributes are required; create with `save(writer: w)` using
275
+ the record's own context's writer. Generated model keys remain unassignable.
276
+ Existing data needs an explicit transactional backfill/deduplication followed
277
+ by `storage.sync_constraints!` on the same connection. No automatic migration
278
+ runs when a declaration is loaded. Other property types, optional uniqueness
279
+ and multiple alternate keys are outside this key API.
280
+
281
+ ## Open properties (0.3.0 candidate)
282
+
283
+ Types stay closed by default. Opt in to extra properties explicitly:
284
+
285
+ ```ruby
286
+ node :Person, open: true do
287
+ property :uid, :string, required: true
288
+ key :uid
289
+ end
290
+ edge :MENTIONS, from: :Person, to: :Person, open: true
291
+
292
+ ref = ctx.insert_node(:Person, properties: { source_note: "archive", scores: [1, 2] })
293
+ ctx.find(ref)[:scores] # => [1, 2], frozen
294
+ ctx.update_node(ref, properties: { source_note: nil }) # removes the extra
295
+ ```
296
+
297
+ An open parent makes its descendants open, including with multiple parents;
298
+ `open: false` on a child does not close an open ancestor. Declared properties,
299
+ keys, endpoints and generated labels retain their rules.
300
+
301
+ Extras accept strings, integers, finite floats, booleans and flat homogeneous
302
+ scalar arrays (including empty arrays). Nil means absent/removal. Maps, nested
303
+ or mixed arrays, nil array members, Symbols, dates/times, decimals and custom
304
+ objects need explicit application conversion or a declared property type.
305
+ Extra names cannot collide with structural row names, regardless of element
306
+ kind (so even a node extra cannot be named `type`, `source_id` or `target_id`).
307
+ Validity names `valid_from`/`valid_to` are reserved even in wholly timeless
308
+ graphs. Application-column names of that element kind are also reserved.
309
+ Invalid names are refused. On open types, duplicate String/Symbol aliases are
310
+ rejected for every submitted property, including declared properties. Closed
311
+ types retain their existing last-alias normalization.
312
+
313
+ Queries, ordering, model attributes and `pluck` still require declarations.
314
+ Models expose extras through their read-only `extra_properties`; saving declared
315
+ attributes preserves them. Write extras through `Context`/`Writer` and reload
316
+ the model to refresh its map. No dynamic query casts or model setters are added.
317
+
318
+ Extras carry application data and gain no automatic tenant/ACL or visibility
319
+ semantics. Existing row/write policies still apply, and write policies can
320
+ inspect submitted extras. Keep security-relevant fields in explicit declarations
321
+ or application columns, with an explicit authorization policy.
322
+
323
+ Openness alone adds no DDL. Closing a type preserves existing extra data on
324
+ reads and declared-only updates; it refuses resubmitted unknown properties.
325
+ Audit/backfill before closing types or promoting extras to typed declarations.
326
+ Shape validation is at the library write boundary; raw SQL does not gain an
327
+ open/closed constraint. The existing required-property and key checks remain.
328
+
329
+ ## Temporal edges (0.3.0 candidate)
330
+
331
+ Opt in per edge type and use one date across the whole query:
332
+
333
+ ```ruby
334
+ edge :MEMBER_OF, from: :Person, to: :Organization, temporal: true
335
+
336
+ w = Kg.for(viewer: current_viewer)
337
+ w.insert_edge(:MEMBER_OF, from: ann, to: org,
338
+ valid_from: Date.new(2020, 1, 1), valid_to: Date.new(2021, 1, 1),
339
+ attrs: { classification: 1 })
340
+
341
+ at = Kg.for(viewer: current_viewer, as_of: Date.new(2020, 6, 1))
342
+ at.match.node(:p, ref: ann).out(:MEMBER_OF, :o).to_a
343
+ all_dates = Kg.for(viewer: current_viewer, as_of: :any)
344
+ ```
345
+
346
+ Intervals include the start and exclude the end. Bounds are exact Ruby Dates
347
+ in 0001–9999 or nil for unbounded; unknown/uncertain dates need separate
348
+ application metadata. Required `valid_from:` presence on insert/upsert makes
349
+ all-time intent explicit (`valid_from: nil`). Updates preserve omitted bounds;
350
+ upserts preserve an omitted `valid_to` on conflict but use nil on insert.
351
+
352
+ Date contexts are read-only, including model saves. Omitting `as_of` permits
353
+ node-only and timeless-only reads; temporal reads raise `TemporalModeRequired`.
354
+ Generic edge `find(ref)` requires a mode whenever the schema has a temporal
355
+ type. `:any` remains writable and always retains current visibility. Dated
356
+ reachability, models and preloads use the same date on every hop. This does not
357
+ reconstruct old values, node existence or past permissions.
358
+
359
+ `unique: :pair` still spans all dates. Leave/rejoin episodes with a gap need
360
+ separate Refs on a non-pair-unique type; overlaps are allowed.
361
+
362
+ Run the temporal migration before routing dated reads or temporal writes to the
363
+ updated graph declaration. Loading `temporal: true` does not migrate its tables.
364
+ During a deploy-then-migrate window, keep temporal edge operations disabled
365
+ until the migration commits. Storage helpers report `TemporalMigrationRequired`;
366
+ ordinary reads/writes do not inspect the catalog, so unmigrated operations may
367
+ raise `ActiveRecord::StatementInvalid` with `PG::UndefinedColumn` instead.
368
+ An explicit `:any` read can still work without date columns and does not prove
369
+ that the migration is complete.
370
+
371
+ For existing tables, remove conflicting application declarations and migrate
372
+ incompatible legacy timestamp columns explicitly before enabling temporal types:
373
+
374
+ ```ruby
375
+ Kg.storage.enable_temporal!(types: [:MEMBER_OF], backfill: ->(connection) {
376
+ # Explicit application backfill, on this connection, after checks are enabled.
377
+ # Without a callback, existing bounds are preserved; newly added NULL bounds
378
+ # deliberately mean all-time. Never invent dates or silently discard times.
379
+ })
380
+ ```
381
+
382
+ The helper locks the edge table, adds missing nullable date columns and runs
383
+ constraint sync/backfill atomically. An outer transaction keeps its work pending
384
+ until commit. Callback errors or invalid intervals roll everything back; the
385
+ callback must not commit or switch connections. Callback idempotency is yours.
386
+ Bare `sync_constraints!` refuses missing columns or unacknowledged populated
387
+ temporal types. Once temporal columns exist, every synchronization takes an
388
+ `ACCESS EXCLUSIVE` edge-table lock, including after the last temporal type is
389
+ disabled and for unrelated schema changes. This blocks edge reads and writes
390
+ until the caller's outer commit; keep the migration transaction short and
391
+ schedule it accordingly. Physical columns remain after temporal types are disabled.
392
+ See [ADR-0013](https://knowen.md/nodes/36532) for the complete contract.
393
+
128
394
  ## Leak specs for your policy
129
395
 
130
396
  ```ruby
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Pglpg
4
4
  # Global settings. Each graph can override visibility, write_policy and
5
- # trusted_writer_allowed when it is defined.
5
+ # trusted_writer_allowed and key_scope_allowed when it is defined.
6
6
  class Configuration
7
7
  # Required. An object with +node(table, viewer)+ and +edge(table, viewer)+,
8
8
  # each returning an Arel condition on the given Arel::Table.
@@ -16,6 +16,9 @@ module Pglpg
16
16
  # may be used for that context. Unset means the capability is never granted.
17
17
  attr_accessor :trusted_writer_allowed
18
18
 
19
+ # Explicit permission to learn key occupancy in a scoped key domain.
20
+ attr_accessor :key_scope_allowed
21
+
19
22
  # The ActiveRecord class whose connection pglpg uses.
20
23
  attr_accessor :connection_class
21
24