% Discovery hub — the public shared-database read side (slice 1). % % This file is loaded as the discovery hub node's shared database % (load_shared_db_file/1), so its predicates are reachable through the % same `/call` API every node already speaks. Discovery is then a % query: finding a node is running a goal against this database. % % ?- node_profile(N, actor), node_status(N, up). % % The facts behind these rules — node_record_gen/3 and current_gen/1 — % are *not* authored here. They are a denormalized read replica that % the registry custodian (discovery_hub.pl) republishes on every state % change. This file is the stateless reader; discovery_hub.pl is the % single writer. See docs/DISCOVERY_HUB_PLAN.md §3.4. :- dynamic node_record_gen/3. % Gen, Id, Record (status-free dict) :- dynamic current_gen/1. % the live generation pointer % --- RELATION profile: the advertised public query surface ---------- % % When the hub runs at WP_PROFILE=relation, these relation_filter/1 % declarations are the *only* relations a client may query over /call; % the relation goal guard refuses everything else — internal helpers % (record_with_status/2, node_live_status/4), arbitrary goals, and % conjunctions alike. Declaring them explicitly keeps the helpers out % of the advertised schema (the source-parse fallback would otherwise % expose every clause head). Inert at the other profiles. relation_filter(node_record(_, _)). relation_filter(node_id(_)). relation_filter(node_url(_, _)). relation_filter(node_profile(_, _)). relation_filter(node_auth(_, _)). relation_filter(node_status(_, _)). relation_filter(node_card(_, _, _, _, _, _)). relation_filter(node_directory_row(_, _, _, _, _, _, _, _, _)). %! node_record(?Id, -Record) is nondet. % % The composed public record for a node, as a dict. The stable % fields come straight from the replica; `status` is *derived at read % time* (never stored) from the observed timestamps, so a node that % died between sweeps cannot leave a stale `up` stranded in the store. % % The cut commits each query to a single generation: during the brief % double-buffer overlap two current_gen/1 facts may coexist, and the % cut takes the first (older, still-complete) one until it is retired, % so no query ever spans two generations (plan §3.4). node_record(Id, Record) :- current_gen(Gen), !, node_record_gen(Gen, Id, Rec0), record_with_status(Rec0, Record). record_with_status(Rec0, Record) :- get_time(NowF), Now is integer(NowF), get_dict(last_seen, Rec0, Seen), get_dict(last_error, Rec0, Err), node_live_status(Now, Seen, Err, Status), put_dict(status, Rec0, Status, Record). %! node_live_status(+Now, +LastSeen, +LastError, -Status) is det. % % `up` needs a *fresh* success that is not superseded by a newer % error; one missed probe after a good one is the amber `unreachable` % band; anything stale (or never seen) is `down`. TTL = 75s ≈ 2×30s % sweep, wide enough that a single dropped probe never flaps a healthy % node to down (plan §4). node_live_status(Now, Seen, Err, Status) :- ( Seen > 0, Now - Seen =< 75 -> ( Err > Seen -> Status = unreachable ; Status = up ) ; Status = down ). %! node_id(?Id) is nondet. %! node_url(?Id, -Url) is nondet. %! node_profile(?Id, -Profile) is nondet. %! node_auth(?Id, -Auth) is nondet. %! node_status(?Id, -Status) is nondet. % % The normalized query relations. Discovery in Web Prolog is a % program, not a protocol stack: these let a client compose % `?- node_profile(N, actor), node_status(N, up).` over plain `/call`. % `profile`/`auth` read `unknown` until the first successful probe, so % the relations only succeed once the node has actually self-reported. % % (The plan §2.2 sketches the membership relation as `node/1`; here it % is `node_id/1` because the node server already owns `node/1` — the % hub's shared module would otherwise shadow it.) node_id(Id) :- current_gen(Gen), !, node_record_gen(Gen, Id, _). node_url(Id, Url) :- node_record(Id, Record), get_dict(url, Record, Url). node_profile(Id, Profile) :- node_record(Id, Record), get_dict(profile, Record, Profile), Profile \== unknown. node_auth(Id, Auth) :- node_record(Id, Record), get_dict(auth, Record, Auth), Auth \== unknown. node_status(Id, Status) :- node_record(Id, Record), get_dict(status, Record, Status). %! node_card(?Id, -Status, -Profile, -Auth, -LatencyMs, -LastSeen) is nondet. % % The flat, JSON-friendly projection the directory UI enumerates over % `/call`: the *dynamic* fields keyed by id (the static seed tier — % description, links — the client already holds). One row per node; % `profile`/`auth` are `unknown` until the node is first probed. node_card(Id, Status, Profile, Auth, LatencyMs, LastSeen) :- node_record(Id, Record), get_dict(status, Record, Status), get_dict(profile, Record, Profile), get_dict(auth, Record, Auth), get_dict(latency_ms, Record, LatencyMs), get_dict(last_seen, Record, LastSeen). %! node_directory_row(?Id, -Status, -Profile, -Auth, -LatencyMs, %! -LastSeen, -Url, -Description, -Note) is nondet. % % The full flat projection the /discovery-hub directory page renders: % the dynamic fields plus the seed tier (url, description, note), all % JSON-friendly, one row per node. node_card/6 stays the minimal % status-only projection; this one carries enough to draw a card. node_directory_row(Id, Status, Profile, Auth, LatencyMs, LastSeen, Url, Description, Note) :- node_record(Id, Record), get_dict(status, Record, Status), get_dict(profile, Record, Profile), get_dict(auth, Record, Auth), get_dict(latency_ms, Record, LatencyMs), get_dict(last_seen, Record, LastSeen), get_dict(url, Record, Url), get_dict(description, Record, Description), get_dict(note, Record, Note).