Skip to content

Commit 547db78

Browse files
authored
Merge pull request #7 from CentralInfraCore/feature/5-address-key-fields-formalization
feat: formalize Address key_fields as Shape+Role
2 parents 7967ace + 821df45 commit 547db78

3 files changed

Lines changed: 190 additions & 7 deletions

File tree

‎proposals/atom-grammar/README.md‎

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,69 @@ Az `opaque` **terminális**: nincs alatta séma-ismert gyermek, és a
220220
validátor nem lép bele. A `contract` továbbra is alkalmazható rá
221221
(pl. `pattern` a szerializált alakra), de a belső szerkezetére nem.
222222
223+
### 2.7 Address `key_fields` — formalizálva
224+
225+
A `binding_surface.addresses[].key_fields` a corpuson eddig `{name, type,
226+
values}` objektumként élt (`compute-resource.yaml:489`, és minden más
227+
domain-objektum ugyanígy) — nem Shape, nem validált, nincs Contract-algebrája.
228+
Ez a hiány külön issue-ként lett rögzítve
229+
(cic-schema-registry-oldalon: `cic-primitives#5`, "Address atom does not
230+
formalize keyed binding"), miután egy konkrét provider-specializáció
231+
(`StorageResourceOracleCloud`) megpróbálta kifejezni a `backend`/`provider`
232+
szűkítését, és nem tudta — nem a specializáció mechanizmusa hiányzott
233+
(`identity.base` már működik), hanem maga a `key_fields` fogalom nem volt
234+
Shape-ként deklarálva, amin a szűkítést el lehetett volna végezni.
235+
236+
**A formalizáció**: a `key_fields` minden eleme egy rendes Shape-node,
237+
`role: key`-vel — ugyanaz a primitívkészlet (Shape/Role/Contract), amit
238+
minden más mező is használ, nem új mechanizmus. A `schemas/atomic/
239+
address.yaml` `fields.properties.key_fields` hordozza (`type: list<Shape>`).
240+
241+
```yaml
242+
binding_surface:
243+
addresses:
244+
- namespace: "cic:storage"
245+
key_fields:
246+
- name: backend
247+
shape_type: scalar
248+
scalar_type: string
249+
role: key
250+
mandatory: true
251+
contract:
252+
- type: enum
253+
expression: [hypervisor, san, cloud]
254+
- name: provider
255+
shape_type: scalar
256+
scalar_type: string
257+
role: key
258+
mandatory: true
259+
- name: location
260+
shape_type: scalar
261+
scalar_type: string
262+
role: key
263+
mandatory: true
264+
- name: id
265+
shape_type: scalar
266+
scalar_type: string
267+
role: key
268+
mandatory: true
269+
```
270+
271+
**Miért nem `item_key`.** A §2.3 jegyzete a fordítottját magyarázza (miért
272+
kapott a Shape-collection összetett kulcsa `item_key` nevet) — ugyanaz a
273+
válasz erről az oldalról nézve: a `key_fields` egy ENTITÁST azonosít a
274+
saját Address-én keresztül, az `item_key` egy ELEMET egyetlen entitás
275+
collection-mezőjén belül. A két fogalom nem esik egybe, csak a nevük
276+
kollidált volna.
277+
278+
**Grammatikai kezelés** (`check_grammar.py`): a `key_fields` felkerült a
279+
`NODE_CONTAINERS`-re, tehát minden eleme rendes Shape-node-ként validálódik
280+
(S2, a JSON Schema réteg). Az `item_fields`-től eltérően — ahol csak
281+
NÉHÁNY elem kulcs, és az `item_key`/egyetlen-`role:key` levezetés dönti el,
282+
melyik — a `key_fields` MINDEN eleme kulcs a definíciójából adódóan, tehát
283+
nincs "levezetés": minden elemnek EXPLICIT `role: key`-t kell hordoznia
284+
(**C16**), és a hiányzó `role` itt hiba, nem levezethető alapértelmezés.
285+
223286
---
224287

225288
## 3. Role — kombinációs algebra (P0.2)
@@ -284,7 +347,7 @@ role:
284347
| `volatile` **kizárja** a `config`-ot | nem perzisztens értéknek nincs kívánt állapota |
285348
| `key` **megköveteli** a `config`-ot | a kulcsot a kérő adja meg |
286349
| `key` **megköveteli** a `mandatory`-t | kulcs nem hiányozhat |
287-
| `key` **csak** `collection.key_fields`-ben álló mezőn | máshol nincs mit azonosítania |
350+
| `key` **csak** `item_key`-ben megnevezett/egyetlen-kulcsos `item_fields` mezőn, VAGY egy Address `key_fields` elemén | máshol nincs mit azonosítania. (Ez a sor korábban `collection.key_fields`-et mondott — a §2.3 döntés, ami az `item_key` nevet választotta pont azért, hogy ne ütközzön a §2.7-ben formalizált Address `key_fields`-szel, itt nem lett átvezetve. Javítva, amikor a §2.7 megszületett.) |
288351
| `key` **kizárja** a `derived`-et és a `volatile`-t | a kulcs a létrehozás után nem változhat |
289352
| `reference` **megköveteli** a `semantic_type: cic-reference`-t | különben nincs mire mutatnia |
290353

@@ -327,6 +390,12 @@ külön dokumentum marad ahelyett, hogy egy nyelv lenne.
327390
| **C8** | minden listának van kulcsa; összetett kulcsnál `item_key` kötelező, és minden eleme `role: key`-es `item_fields` elem | §2.3 |
328391
| **C9** | `semantic_type: cic-reference` ⇔ `reference_target` | egyik sem állhat a másik nélkül |
329392
| **C10** | `reference_target` MUST `{namespace}:{Kind}` alakú legyen, és a `{namespace}` MUST `cic:`-vel kezdődjön | §2.4 |
393+
| **C16** | Address `key_fields` minden eleme MUST `role: key`-t hordozzon explicit | §2.7 — nincs "levezetés", mert nincs nem-kulcs elem, amitől meg kéne különböztetni |
394+
395+
(C11–C15 a kódban léteznek — `check_role_algebra`/`check_default_against_role`/
396+
`check_role_against_surface`/`check_duplicate_names` — de sosem kerültek be
397+
ebbe a táblázatba. Nem ez a szál pótolja őket; a C16-ot azért a valódi
398+
következő szabad szám alá vettem fel, nem C11 alá, hogy ne ütközzön velük.)
330399

331400
### Amit szándékosan NEM zárok le
332401

‎proposals/atom-grammar/check_grammar.py‎

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,22 @@ def expand_role(raw, path: str, out: list[Finding],
156156
# because they happen to carry `shape_type`. When these members hold a STRING
157157
# instead of a list they are a reference, and C6/C7 resolve them; the isinstance
158158
# guard below keeps the two cases apart.
159-
NODE_CONTAINERS = ("nodes", "fields", "item_fields", "input", "output", "payload")
159+
#
160+
# `key_fields` (an Address's key components — README §2.7) is a fourth case
161+
# added later, not from diffing the walker but from the corpus itself: every
162+
# `binding_surface.addresses[].key_fields` entry across the live corpus (e.g.
163+
# `cic-schema-registry` general/storage/storage-resource) is written as
164+
# `{name, type, values}`, structurally identical to a field descriptor in
165+
# every way that matters except the member name `type` instead of
166+
# `shape_type` — a naming accident, not a different kind of thing. Formalizing
167+
# it here (instead of leaving it corpus-convention-only, as it was) is what
168+
# lets a Shape/Contract narrowing question about it be asked and checked at
169+
# all. See §2.7 for why this is NOT the same position as `item_key`/
170+
# `item_fields`, despite the superficial name collision `key_fields` almost
171+
# caused (§2.3's note on why the Shape-collection composite key was named
172+
# `item_key` instead).
173+
NODE_CONTAINERS = ("nodes", "fields", "item_fields", "input", "output",
174+
"payload", "key_fields")
160175

161176

162177
def _node_children(node: dict, here: str):
@@ -205,7 +220,17 @@ def walk_nodes(obj, path: str = "$", in_item_key: frozenset[str] = frozenset(),
205220
continue
206221
for item in entries:
207222
if isinstance(item, dict):
208-
yield from _walk_node(item, f"{path}.{key}", in_item_key,
223+
# Every entry of an Address's key_fields is a key
224+
# component by definition — unlike item_fields, which
225+
# holds both key and non-key fields side by side and
226+
# needs item_key/role: key to say which is which,
227+
# key_fields has no non-key entries to distinguish from
228+
# (§2.7). So its own name is always "in the key set"
229+
# here, regardless of whatever in_item_key carried down
230+
# from an enclosing collection.
231+
item_in_key = (frozenset({item.get("name")})
232+
if key == "key_fields" else in_item_key)
233+
yield from _walk_node(item, f"{path}.{key}", item_in_key,
209234
surface)
210235
for k, v in obj.items():
211236
# The containers above are fully handled by _walk_node, and `cases`
@@ -452,7 +477,8 @@ def check_role_algebra(node, role, path, is_key_position, out: list[Finding]) ->
452477
out.append(Finding(
453478
"R-KEY", path,
454479
"`role: key` is only meaningful on a field listed in the "
455-
"enclosing collection's item_key"))
480+
"enclosing collection's item_key, or on an entry of an "
481+
"Address's key_fields (§2.7)"))
456482

457483
if "reference" in structural and node.get("semantic_type") != "cic-reference":
458484
out.append(Finding(
@@ -512,6 +538,39 @@ def check_collection(node, path, out: list[Finding]) -> None:
512538
f"`{krole_name}`, not key"))
513539

514540

541+
def check_address_key_fields(obj, path: str, out: list[Finding]) -> None:
542+
"""C16 — every entry of an Address's key_fields carries `role: key` (§2.7).
543+
544+
Unlike item_fields, key_fields has no non-key entries to tell a key
545+
apart from by role — everything in it identifies the entity. So the role
546+
is not optional/derivable the way a single-key item_fields list lets it
547+
be (§2.3): a key_fields entry that omits `role` would otherwise pass
548+
silently, since `expand_role(None, ...)` treats a missing role as a
549+
valid empty one, not an error (see check_document). This check is what
550+
turns "the field is where a key must be" into "the field also has to
551+
say so."
552+
"""
553+
if isinstance(obj, dict):
554+
entries = obj.get("key_fields")
555+
if isinstance(entries, list):
556+
for item in entries:
557+
if not isinstance(item, dict):
558+
continue
559+
name = item.get("name", "?")
560+
if not is_key_role(item.get("role")):
561+
out.append(Finding(
562+
"C16", f"{path}.key_fields.{name}",
563+
f"`{name}` is a key_fields entry but its role is "
564+
f"`{item.get('role')!r}`, not key — every key_fields "
565+
"entry identifies the entity, so none of them are "
566+
"optional about saying so"))
567+
for k, v in obj.items():
568+
check_address_key_fields(v, f"{path}.{k}", out)
569+
elif isinstance(obj, list):
570+
for item in obj:
571+
check_address_key_fields(item, path, out)
572+
573+
515574
def check_reference_target(node, path, out: list[Finding]) -> None:
516575
"""C10 — {namespace}:{Kind}, and the namespace itself carries the cic: prefix."""
517576
target = node.get("reference_target")
@@ -581,6 +640,7 @@ def check_document(doc, validator) -> list[Finding]:
581640
check_reference_target(node, path, out)
582641
check_duplicate_names(doc, "$", out)
583642
check_shape_references(doc, out)
643+
check_address_key_fields(doc, "$", out)
584644
return out
585645

586646

@@ -736,6 +796,32 @@ def check_document(doc, validator) -> list[Finding]:
736796
"scalar_type": "string", "role": "key",
737797
"mandatory": True},
738798
{"name": "broken"}]}]}}},
799+
"an Address key_fields entry that omits role: key": {
800+
"spec": {"binding_surface": {"addresses": [
801+
{"namespace": "cic:storage", "key_fields": [
802+
{"name": "backend", "shape_type": "scalar",
803+
"scalar_type": "string", "mandatory": True,
804+
"contract": [{"type": "enum",
805+
"expression": ["hypervisor", "san", "cloud"]}]}]}
806+
]}}},
807+
}
808+
809+
MUST_ACCEPT_DOCS = {
810+
"the corpus's real Address key_fields, formalized (§2.7)": {
811+
"spec": {"binding_surface": {"addresses": [
812+
{"namespace": "cic:storage", "key_fields": [
813+
{"name": "backend", "shape_type": "scalar",
814+
"scalar_type": "string", "role": "key", "mandatory": True,
815+
"contract": [{"type": "enum",
816+
"expression": ["hypervisor", "san", "cloud"]}]},
817+
{"name": "provider", "shape_type": "scalar",
818+
"scalar_type": "string", "role": "key", "mandatory": True},
819+
{"name": "location", "shape_type": "scalar",
820+
"scalar_type": "string", "role": "key", "mandatory": True},
821+
{"name": "id", "shape_type": "scalar",
822+
"scalar_type": "string", "role": "key", "mandatory": True},
823+
]}
824+
]}}},
739825
}
740826

741827
MUST_ACCEPT = {
@@ -814,8 +900,9 @@ def self_test(validator) -> int:
814900
failures += 1
815901

816902
print("\n--- must be accepted ---")
817-
for label, node in MUST_ACCEPT.items():
818-
findings = check_document(as_document(node), validator)
903+
for label, node in {**{k: as_document(v) for k, v in MUST_ACCEPT.items()},
904+
**MUST_ACCEPT_DOCS}.items():
905+
findings = check_document(node, validator)
819906
if not findings:
820907
print(f" \033[92maccepted\033[0m {label}")
821908
else:
@@ -824,7 +911,8 @@ def self_test(validator) -> int:
824911
print(f)
825912
failures += 1
826913

827-
total = len(MUST_REJECT) + len(MUST_REJECT_DOCS) + len(MUST_ACCEPT)
914+
total = (len(MUST_REJECT) + len(MUST_REJECT_DOCS)
915+
+ len(MUST_ACCEPT) + len(MUST_ACCEPT_DOCS))
828916
print(f"\nself-test: {total - failures}/{total}")
829917
return 1 if failures else 0
830918

‎schemas/atomic/address.yaml‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,32 @@ spec:
6666
description: >
6767
CIC namespace in which this address is interpreted.
6868
E.g. cic:network, cic:kubernetes, cic:compute
69+
key_fields:
70+
type: list<Shape>
71+
optional: true
72+
description: >
73+
Ordered list of Shape-typed key components that together identify
74+
an entity within this Address's namespace (the corpus convention
75+
{backend}:{provider}:{location}:{id}, composed into logical_id as
76+
cic:{namespace}:{backend}:{provider}:{location}:{id}). Every entry
77+
MUST be a Shape (shape_type: scalar in every corpus occurrence —
78+
nothing has needed a composite or collection key component yet)
79+
with role: key, per proposals/atom-grammar/README.md §2.7. Formalized
80+
here rather than left as a bare {name, type, values} corpus
81+
convention, which carried no Shape/Contract typing and so could
82+
not be validated at all (cic-schema-registry cic-primitives#5).
83+
84+
Order matters and is significant: it fixes the position of each
85+
component in logical_id and in schema_path/api_path's key
86+
expression, the same way a YANG list key's declared order does.
87+
88+
Distinct from a Shape-collection's own item_key (proposals/
89+
atom-grammar/README.md §2.3) despite the near-identical name in
90+
the corpus's pre-formalization convention: item_key identifies one
91+
ELEMENT within a single entity's own collection-typed field;
92+
key_fields identifies the ENTITY itself via its Address. The two
93+
were kept apart on purpose when item_key was named, specifically
94+
because key_fields was already taken by this field.
6995
note:
7096
type: string
7197
optional: true

0 commit comments

Comments
 (0)