Skip to content
Merged
65 changes: 42 additions & 23 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,29 +281,48 @@ from the rest of the permanent tier (a DB read in phase 1, which today does
no DB access) — not worth it unless a caller needs to branch on the
specific reason. Revisit if that need arises.

### Template attribute list and instance-only enforcement (slice C, depends on slice B)

A template currently carries only a name and its compositional arc into
the owning class — there is no per-template list of which attributes the
template scopes. Without it, the class write-side cannot distinguish two
categories of attribute a class declares:

- **Class-bindable** — the class may supply a value (or a useful
default); instances inherit it and may override. *Example:
`num_wheels = 4` on a Car class.*
- **Instance-only** — the class declares the attribute as relevant, but
binding a value at the class level is a category error; the value is
meaningful only per instance. *Example: `serial_number`,
`owner_name`.* Binding a class-level value for such an attribute should
be rejected.

The distinction is per-class, per-template — the same attribute may be
class-bindable in one class's template and instance-only in another's.
Build the template attribute list, then enforce instance-only rejection
in `create_class` and `update_node_avps`. The unified qualifying-
characteristic AVP shape (a declared-but-unbound attribute carries
`value => undefined`) already accommodates an instance-only attribute
naturally — it stays `undefined` at every class level.
### Instance-only qualifying characteristics (slice C) — IMPLEMENTED

A class QC may be marked `instance_only => true`: the attribute is relevant
to instances, but binding a value at the class level is a category error.
Set via `graphdb_class:add_qualifying_characteristic/3` (`#{instance_only =>
true}`) or a `create_class/3` initial AVP. Enforced at three class-level
value-binding gates — `bind_qc_value/3`, `create_class/3`, and
`update_node_avps/2` (the last covers `mutate/1`, both composing
`update_node_avps_in_txn/3`) — each returning `{error,
{instance_only_attribute, AttrNref}}`. Enforcement is local to the class
node written. Design `docs/designs/slice-c-instance-only-qc-design.md`.

**Deferred follow-ons (from slice C):**

- **Template attribute list** — per-template subset/relevance scoping of
class attributes (`TheKnowledgeNetwork.md` §7). A template currently
carries only a name and its compositional arc into the owning class; there
is no per-template list of which attributes it scopes. This is the
per-class, per-template axis: the same attribute may be class-bindable in
one class's template and instance-only in another's.
- **Template-bound (variant) values** — templates carrying override values
stamped into instances at instantiation (e.g. a later custom-colour phone
variant whose colour is fixed in a template, not on the base class).
- **Inherited instance-only enforcement (C2)** — close the subclass-redeclare
bypass: a subclass can re-declare a parent's instance-only QC *without* the
flag via `add_qualifying_characteristic/2`, then bind a value. Local gates
do not consult the inherited QC set because `collect_qc_avps/1` flattens
each QC to `{AttrNref, Value}`, dropping the marker. Closing it means
carrying the flag through `collect_qc_avps/1` / `inherited_qcs/1` and
having all three gates consult the effective (local + ancestor) QC set.
- **Marker mutability via the general update path** — today `instance_only`
is settable only at QC declaration (`add_qualifying_characteristic/3`) or
`create_class/3`; it can be neither set nor cleared through
`update_node_avps/2` / `mutate/1`. That restriction is a *side effect* of
slice B's AVP well-formedness check (which rejects any update map whose
key-set is not exactly `[attribute]` or `[attribute, value]`), **not** a
deliberate long-term contract decision. Investigate whether toggling a
QC's instance-only status — and QC-shape edits generally — should be a
first-class mutation: a dedicated mutation kind, or a widened update
grammar that admits marker keys, versus remaining declaration-time only.
Decide and document the intended contract before any caller comes to
depend on the current behaviour.

### Relationship mutation (slice E)

Expand Down
2 changes: 1 addition & 1 deletion apps/graphdb/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ the ontology `nodes` Mnesia table with `kind = attribute`.
Manages the "is a" hierarchy of class nodes in the ontology.

- `create_class/2,3` (name, parent_class_nref [, avps]) — the `/3` form prepends an initial AVP list to the class node; a class created with the `instantiable => false` marker AVP is **abstract** and is born without a default template (L9)
- `add_qualifying_characteristic/2` (class_nref, attribute_nref)
- `add_qualifying_characteristic/2,3` (class_nref, attribute_nref [, opts]) — the `/3` form takes an options map; `#{instance_only => true}` marks the QC instance-only (binding a class-level value for it is rejected at `bind_qc_value/3`, `create_class/3`, and `update_node_avps/2`)
- `is_instantiable/1` (class_nref) — `false` iff the class carries the `instantiable => false` marker
- `get_class/1`, `subclasses/1`, `ancestors/1`, `inherited_qcs/1`
- `get_template_in_txn/1`, `class_in_ancestry_in_txn/2`,
Expand Down
183 changes: 133 additions & 50 deletions apps/graphdb/src/graphdb_class.erl
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
create_class/3,
add_superclass/2,
add_qualifying_characteristic/2,
add_qualifying_characteristic/3,
bind_qc_value/3,
add_template/2,
%% Lookups
Expand Down Expand Up @@ -154,7 +155,9 @@
-ifdef(TEST).
-export([
is_valid_parent_kind/1,
collect_qc_avps/1
collect_qc_avps/1,
is_instance_only/1,
validate_instance_only_avps/1
]).
-endif.

Expand Down Expand Up @@ -232,6 +235,10 @@ add_template(ClassNref, Name) ->
add_qualifying_characteristic(ClassNref, AttrNref) ->
gen_server:call(?MODULE, {add_qualifying_characteristic, ClassNref, AttrNref}).

add_qualifying_characteristic(ClassNref, AttrNref, Opts) when is_map(Opts) ->
gen_server:call(?MODULE,
{add_qualifying_characteristic, ClassNref, AttrNref, Opts}).


%%-----------------------------------------------------------------------------
%% bind_qc_value(ClassNref, AttrNref, Value) -> ok | {error, term()}
Expand Down Expand Up @@ -370,6 +377,10 @@ handle_call({add_qualifying_characteristic, ClassNref, AttrNref}, _From,
State) ->
{reply, do_add_qc(ClassNref, AttrNref), State};

handle_call({add_qualifying_characteristic, ClassNref, AttrNref, Opts}, _From,
State) ->
{reply, do_add_qc(ClassNref, AttrNref, Opts), State};

handle_call({bind_qc_value, ClassNref, AttrNref, Value}, _From, State) ->
{reply, do_bind_qc_value(ClassNref, AttrNref, Value), State};

Expand Down Expand Up @@ -469,47 +480,52 @@ is_valid_parent_kind(_) -> false.
%% nref/id allocation stays outside the transaction.
%%-----------------------------------------------------------------------------
do_create_class(Name, ParentClassNref, AVPs, InstAttr) ->
case do_validate_parent(ParentClassNref) of
ok ->
ClassNref = graphdb_nref:get_next(),
{TaxId1, TaxId2} = rel_id_server:get_id_pair(),
ClassNameAVP = #{attribute => ?NAME_ATTR_CLASS, value => Name},
ClassNode = #node{
nref = ClassNref,
kind = class,
parents = [ParentClassNref],
attribute_value_pairs = [ClassNameAVP | AVPs]
},
TaxP2C = #relationship{
id = TaxId1, kind = taxonomy,
source_nref = ParentClassNref,
characterization = ?ARC_CLS_CHILD,
target_nref = ClassNref,
reciprocal = ?ARC_CLS_PARENT,
avps = []
},
TaxC2P = #relationship{
id = TaxId2, kind = taxonomy,
source_nref = ClassNref,
characterization = ?ARC_CLS_PARENT,
target_nref = ParentClassNref,
reciprocal = ?ARC_CLS_CHILD,
avps = []
},
TemplateRows = template_rows(ClassNref, AVPs, InstAttr),
Txn = fun() ->
ok = mnesia:write(nodes, ClassNode, write),
ok = mnesia:write(relationships, TaxP2C, write),
ok = mnesia:write(relationships, TaxC2P, write),
[ ok = mnesia:write(T, R, write) || {T, R} <- TemplateRows ]
end,
case graphdb_mgr:transaction(Txn) of
%% Txn value is [] (abstract) or [ok,ok,ok] (template rows)
{ok, _Writes} -> {ok, ClassNref};
{error, _} = Err -> Err
end;
case validate_instance_only_avps(AVPs) of
{error, _} = Err ->
Err
Err;
ok ->
case do_validate_parent(ParentClassNref) of
ok ->
ClassNref = graphdb_nref:get_next(),
{TaxId1, TaxId2} = rel_id_server:get_id_pair(),
ClassNameAVP = #{attribute => ?NAME_ATTR_CLASS, value => Name},
ClassNode = #node{
nref = ClassNref,
kind = class,
parents = [ParentClassNref],
attribute_value_pairs = [ClassNameAVP | AVPs]
},
TaxP2C = #relationship{
id = TaxId1, kind = taxonomy,
source_nref = ParentClassNref,
characterization = ?ARC_CLS_CHILD,
target_nref = ClassNref,
reciprocal = ?ARC_CLS_PARENT,
avps = []
},
TaxC2P = #relationship{
id = TaxId2, kind = taxonomy,
source_nref = ClassNref,
characterization = ?ARC_CLS_PARENT,
target_nref = ParentClassNref,
reciprocal = ?ARC_CLS_CHILD,
avps = []
},
TemplateRows = template_rows(ClassNref, AVPs, InstAttr),
Txn = fun() ->
ok = mnesia:write(nodes, ClassNode, write),
ok = mnesia:write(relationships, TaxP2C, write),
ok = mnesia:write(relationships, TaxC2P, write),
[ ok = mnesia:write(T, R, write) || {T, R} <- TemplateRows ]
end,
case graphdb_mgr:transaction(Txn) of
%% Txn value is [] (abstract) or [ok,ok,ok] (template rows)
{ok, _Writes} -> {ok, ClassNref};
{error, _} = Err -> Err
end;
{error, _} = Err ->
Err
end
end.


Expand Down Expand Up @@ -967,14 +983,21 @@ do_validate_parent(Nref) ->

%%-----------------------------------------------------------------------------
%% do_add_qc(ClassNref, AttrNref) -> ok | {error, term()}
%% do_add_qc(ClassNref, AttrNref, Opts) -> ok | {error, term()}
%%
%% Adds the qualifying-characteristic AVP to the class node. /2 is the
%% plain declared-unbound form (delegates to /3 with empty Opts). /3
%% accepts Opts = #{instance_only => true} to stamp the instance-only
%% marker onto the canonical declared-unbound shape.
%%
%% Adds the qualifying-characteristic AVP to the class node using the
%% unified shape: #{attribute => AttrNref, value => undefined}.
%% Validates both ClassNref (must be class) and AttrNref (must be
%% attribute). Idempotent: if any entry for AttrNref already exists
%% (regardless of value), leaves it alone and returns ok.
%% (regardless of value or flags), leaves it alone and returns ok.
%%-----------------------------------------------------------------------------
do_add_qc(ClassNref, AttrNref) ->
do_add_qc(ClassNref, AttrNref, #{}).

do_add_qc(ClassNref, AttrNref, Opts) ->
Txn = fun() ->
case mnesia:read(nodes, ClassNref) of
[#node{kind = class, attribute_value_pairs = AVPs} = Node] ->
Expand All @@ -987,8 +1010,7 @@ do_add_qc(ClassNref, AttrNref) ->
true ->
already_exists;
false ->
NewAVP = #{attribute => AttrNref,
value => undefined},
NewAVP = new_qc_avp(AttrNref, Opts),
Updated = Node#node{
attribute_value_pairs = AVPs ++ [NewAVP]
},
Expand All @@ -1013,6 +1035,20 @@ do_add_qc(ClassNref, AttrNref) ->
{error, Reason} -> {error, Reason}
end.

%%-----------------------------------------------------------------------------
%% new_qc_avp(AttrNref, Opts) -> map()
%%
%% Builds the QC AVP for a fresh declaration. `instance_only => true` in
%% Opts stamps the marker onto the canonical declared-unbound shape;
%% otherwise the plain declared-unbound shape is returned.
%%-----------------------------------------------------------------------------
new_qc_avp(AttrNref, Opts) ->
Base = #{attribute => AttrNref, value => undefined},
case maps:get(instance_only, Opts, false) of
true -> Base#{instance_only => true};
false -> Base
end.


%%-----------------------------------------------------------------------------
%% do_bind_qc_value(ClassNref, AttrNref, Value) -> ok | {error, term()}
Expand All @@ -1034,10 +1070,17 @@ do_bind_qc_value(ClassNref, AttrNref, Value) ->
false ->
mnesia:abort(qc_not_declared);
true ->
NewAVPs = update_qc_value(AVPs, AttrNref, Value),
mnesia:write(nodes,
N#node{attribute_value_pairs = NewAVPs},
write)
case is_qc_instance_only(AVPs, AttrNref) of
true ->
mnesia:abort(
{instance_only_attribute, AttrNref});
false ->
NewAVPs = update_qc_value(AVPs, AttrNref,
Value),
mnesia:write(nodes,
N#node{attribute_value_pairs = NewAVPs},
write)
end
end;
[_] -> mnesia:abort(not_a_class);
[] -> mnesia:abort(not_found)
Expand All @@ -1061,6 +1104,18 @@ update_qc_value(AVPs, AttrNref, Value) ->
_ -> A
end || A <- AVPs].

%%-----------------------------------------------------------------------------
%% is_qc_instance_only(AVPs, AttrNref) -> boolean()
%%
%% True iff the QC entry for AttrNref in AVPs carries the instance_only
%% marker. Caller has already verified AttrNref is present.
%%-----------------------------------------------------------------------------
is_qc_instance_only(AVPs, AttrNref) ->
lists:any(fun(#{attribute := A} = E) when A =:= AttrNref ->
is_instance_only(E);
(_) -> false
end, AVPs).


%%-----------------------------------------------------------------------------
%% do_get_class(Nref) ->
Expand Down Expand Up @@ -1191,3 +1246,31 @@ collect_qc_avps(Nodes) ->
end
end, Acc, AVPs)
end, [], Nodes).


%%-----------------------------------------------------------------------------
%% is_instance_only(QcMap) -> boolean()
%%
%% True iff a qualifying-characteristic AVP map carries the
%% `instance_only => true` marker. Pure; consumed by the bind_qc_value
%% and create_class enforcement gates.
%%-----------------------------------------------------------------------------
is_instance_only(#{instance_only := true}) -> true;
is_instance_only(_) -> false.


%%-----------------------------------------------------------------------------
%% validate_instance_only_avps(AVPs) ->
%% ok | {error, {instance_only_attribute, integer()}}
%%
%% Rejects an initial create_class AVP list in which any entry is both
%% marked `instance_only => true` AND carries a concrete (non-undefined)
%% value. An instance-only QC declared unbound (value => undefined) is
%% accepted. Pure; returns the first offending attribute nref.
%%-----------------------------------------------------------------------------
validate_instance_only_avps(AVPs) ->
case [A || #{attribute := A, value := V} = E <- AVPs,
V =/= undefined, is_instance_only(E)] of
[] -> ok;
[A | _] -> {error, {instance_only_attribute, A}}
end.
30 changes: 29 additions & 1 deletion apps/graphdb/src/graphdb_mgr.erl
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,8 @@
validate_direction/1,
check_category_guard/1,
validate_avp_updates/1,
apply_avp_updates/2
apply_avp_updates/2,
check_instance_only/2
]).
-endif.

Expand Down Expand Up @@ -755,6 +756,7 @@ update_node_avps_in_txn(Nref, AVPs, RetAttr) ->
mnesia:abort(not_found);
[Node] ->
ok = guard_retired_marker(AVPs, RetAttr),
ok = guard_instance_only(Node#node.attribute_value_pairs, AVPs),
ok = guard_attribute_existence(AVPs),
New = apply_avp_updates(Node#node.attribute_value_pairs, AVPs),
mnesia:write(nodes, Node#node{attribute_value_pairs = New}, write)
Expand All @@ -780,6 +782,32 @@ guard_attribute_existence(AVPs) ->
end, Upserts),
ok.

%%-----------------------------------------------------------------------------
%% check_instance_only(StoredAVPs, Updates) ->
%% ok | {error, {instance_only_attribute, integer()}}
%%
%% Pure. A value-bearing update (one carrying a `value` key, including
%% value => undefined) targeting a stored entry marked
%% `instance_only => true` is rejected. Deletes (no `value` key) and
%% updates to non-marked attributes pass. Returns the first offender.
%%-----------------------------------------------------------------------------
check_instance_only(StoredAVPs, Updates) ->
Marked = [A || #{attribute := A} = E <- StoredAVPs,
maps:get(instance_only, E, false) =:= true],
ValueBearing = [A || #{attribute := A} = M <- Updates,
maps:is_key(value, M)],
case [A || A <- ValueBearing, lists:member(A, Marked)] of
[] -> ok;
[A | _] -> {error, {instance_only_attribute, A}}
end.

%% In-txn guard: aborts on the first instance-only violation.
guard_instance_only(StoredAVPs, Updates) ->
case check_instance_only(StoredAVPs, Updates) of
ok -> ok;
{error, {instance_only_attribute, _} = R} -> mnesia:abort(R)
end.

%%-----------------------------------------------------------------------------
%% set_marker(AVPs, RetAttr, Bool) -> AVPs'
%% Removes any existing `retired` AVP; if Bool is true, appends a fresh
Expand Down
Loading
Loading