Skip to content

Commit 806f17a

Browse files
committed
release: 0.0.0-alpha.5
1 parent 09e2842 commit 806f17a

File tree

13 files changed

+1018
-51
lines changed

13 files changed

+1018
-51
lines changed

docs/specification/links.mdx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ description: Link statement pattern rules.
66
Link statements describe how one entity is connected to another. In this pattern, the predicate names the relationship and the object identifies the related entity. The object is used as another entity in the relationship, not as the value of a property.
77

88
- SHOULD use this pattern when the goal of the statement is to relate the subject to another entity as an entity.
9+
- SHOULD NOT author both directions of the same inverse relationship for the same fact.
910

1011
## Link Statement Parts [#link-statement-parts]
1112

@@ -46,3 +47,17 @@ Use [Attributes](/context-protocol/specification/attributes) when the object fun
4647
```text
4748
Person | schema:name | "Alice"
4849
```
50+
51+
## Inverse Direction [#inverse-direction]
52+
53+
Some link predicates have inverse forms, such as `schema:hasPart` and `schema:isPartOf`.
54+
55+
Both directions may be valid at the protocol level, but producers SHOULD choose one canonical authored direction for a given fact and avoid storing both directions redundantly.
56+
57+
For part-whole relationships, FCP strongly encourages `schema:hasPart` as the canonical authored direction.
58+
59+
```text
60+
Neo4j Labs | schema:hasPart | Neo4j Labs MCP Servers
61+
```
62+
63+
Consumers MAY interpret or query the inverse relationship when needed without requiring that the inverse statement also be authored.

examples/1.md

Lines changed: 480 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/env node
2+
3+
import { mkdir, writeFile } from "node:fs/promises";
4+
import { dirname, resolve } from "node:path";
5+
import { fileURLToPath } from "node:url";
6+
import { loadValidatedStatementPolicySpec } from "../lib/statement-policy-spec.mjs";
7+
8+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
9+
const PACKAGE_ROOT = resolve(SCRIPT_DIR, "..", "..");
10+
const SDK_SPEC_OUTPUT = resolve(PACKAGE_ROOT, "sdk/javascript/src/spec/index.ts");
11+
12+
function buildSpecModule(spec) {
13+
return `/**
14+
* Generated from \`packages/fide-context-protocol/spec/v0/statement-policy.json\`.
15+
* Do not edit directly; regenerate from the spec source of truth.
16+
*/
17+
export const FCP_STATEMENT_POLICY = ${JSON.stringify(spec, null, 2)} as const;
18+
19+
export const FCP_PREDICATE_ROLE = FCP_STATEMENT_POLICY.predicateRole;
20+
export const FCP_FORBIDDEN_PREDICATES = FCP_STATEMENT_POLICY.forbiddenPredicates;
21+
export const FCP_TYPE_ASSERTION_PREDICATES = FCP_STATEMENT_POLICY.typeAssertionPredicates;
22+
export const FCP_STATEMENT_GUIDE_RULES = FCP_STATEMENT_POLICY.guideRules;
23+
export const FCP_CANONICAL_INVERSE_PREDICATES = FCP_STATEMENT_POLICY.canonicalInversePredicates;
24+
25+
export type FcpPredicateRole = typeof FCP_PREDICATE_ROLE;
26+
export type FcpForbiddenPredicateRule = (typeof FCP_FORBIDDEN_PREDICATES)[number];
27+
export type FcpStatementGuideRule = (typeof FCP_STATEMENT_GUIDE_RULES)[number];
28+
export type FcpCanonicalInversePredicateRule = (typeof FCP_CANONICAL_INVERSE_PREDICATES)[number];
29+
`;
30+
}
31+
32+
async function main() {
33+
const spec = await loadValidatedStatementPolicySpec(PACKAGE_ROOT);
34+
await mkdir(dirname(SDK_SPEC_OUTPUT), { recursive: true });
35+
await writeFile(SDK_SPEC_OUTPUT, buildSpecModule(spec), "utf8");
36+
console.log(
37+
`Generated FCP SDK spec module for ${spec.forbiddenPredicates.length} forbidden predicate rules and ${spec.canonicalInversePredicates.length} canonical inverse predicate rules.`,
38+
);
39+
}
40+
41+
main().catch((error) => {
42+
console.error(error);
43+
process.exit(1);
44+
});
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { readFile } from "node:fs/promises";
2+
import { resolve } from "node:path";
3+
4+
function isObject(value) {
5+
return typeof value === "object" && value !== null && !Array.isArray(value);
6+
}
7+
8+
function fail(errors) {
9+
const message = ["Statement policy spec validation failed:", ...errors.map((e) => `- ${e}`)].join("\n");
10+
throw new Error(message);
11+
}
12+
13+
function validateRule(rule, path, errors) {
14+
if (!isObject(rule)) {
15+
errors.push(`${path} must be an object`);
16+
return;
17+
}
18+
if (typeof rule.id !== "string" || rule.id.length === 0) {
19+
errors.push(`${path}.id must be a non-empty string`);
20+
}
21+
if (typeof rule.description !== "string" || rule.description.length === 0) {
22+
errors.push(`${path}.description must be a non-empty string`);
23+
}
24+
if (typeof rule.path !== "string" || rule.path.length === 0) {
25+
errors.push(`${path}.path must be a non-empty string`);
26+
}
27+
}
28+
29+
export async function loadValidatedStatementPolicySpec(fcpRoot) {
30+
const specPath = resolve(fcpRoot, "spec/v0/statement-policy.json");
31+
const schemaPath = resolve(fcpRoot, "spec/v0/statement-policy.schema.json");
32+
33+
const spec = JSON.parse(await readFile(specPath, "utf8"));
34+
const schema = JSON.parse(await readFile(schemaPath, "utf8"));
35+
const errors = [];
36+
37+
if (!isObject(spec)) {
38+
fail(["spec root must be an object"]);
39+
}
40+
41+
const rootRequired = schema.required ?? [];
42+
for (const key of rootRequired) {
43+
if (!(key in spec)) errors.push(`missing required root field: ${key}`);
44+
}
45+
46+
const specVersionPattern = new RegExp(schema.properties.specVersion.pattern);
47+
const datePattern = new RegExp(schema.properties.specDate.pattern);
48+
49+
if (typeof spec.namespaceUrl !== "string" || spec.namespaceUrl.length === 0) {
50+
errors.push("namespaceUrl must be a non-empty URI string");
51+
}
52+
if (typeof spec.specVersion !== "string" || !specVersionPattern.test(spec.specVersion)) {
53+
errors.push(`specVersion must match ${schema.properties.specVersion.pattern}`);
54+
}
55+
if (typeof spec.specDate !== "string" || !datePattern.test(spec.specDate)) {
56+
errors.push(`specDate must match ${schema.properties.specDate.pattern}`);
57+
}
58+
59+
if (!isObject(spec.predicateRole)) {
60+
errors.push("predicateRole must be an object");
61+
} else {
62+
if (spec.predicateRole.entityType !== "Concept") {
63+
errors.push("predicateRole.entityType must equal Concept");
64+
}
65+
if (spec.predicateRole.referenceType !== "NetworkResource") {
66+
errors.push("predicateRole.referenceType must equal NetworkResource");
67+
}
68+
if (typeof spec.predicateRole.description !== "string" || spec.predicateRole.description.length === 0) {
69+
errors.push("predicateRole.description must be a non-empty string");
70+
}
71+
if (typeof spec.predicateRole.path !== "string" || spec.predicateRole.path.length === 0) {
72+
errors.push("predicateRole.path must be a non-empty string");
73+
}
74+
}
75+
76+
if (!Array.isArray(spec.forbiddenPredicates)) {
77+
errors.push("forbiddenPredicates must be an array");
78+
} else {
79+
for (const [index, rule] of spec.forbiddenPredicates.entries()) {
80+
validateRule(rule, `forbiddenPredicates[${index}]`, errors);
81+
if (typeof rule?.predicateIri !== "string" || !rule.predicateIri.startsWith("https://")) {
82+
errors.push(`forbiddenPredicates[${index}].predicateIri must be an https URI string`);
83+
}
84+
}
85+
}
86+
87+
if (!Array.isArray(spec.typeAssertionPredicates)) {
88+
errors.push("typeAssertionPredicates must be an array");
89+
} else {
90+
for (const [index, iri] of spec.typeAssertionPredicates.entries()) {
91+
if (typeof iri !== "string" || !iri.startsWith("https://")) {
92+
errors.push(`typeAssertionPredicates[${index}] must be an https URI string`);
93+
}
94+
}
95+
}
96+
97+
if (!Array.isArray(spec.guideRules)) {
98+
errors.push("guideRules must be an array");
99+
} else {
100+
for (const [index, rule] of spec.guideRules.entries()) {
101+
validateRule(rule, `guideRules[${index}]`, errors);
102+
}
103+
}
104+
105+
if (!Array.isArray(spec.canonicalInversePredicates)) {
106+
errors.push("canonicalInversePredicates must be an array");
107+
} else {
108+
for (const [index, rule] of spec.canonicalInversePredicates.entries()) {
109+
if (!isObject(rule)) {
110+
errors.push(`canonicalInversePredicates[${index}] must be an object`);
111+
continue;
112+
}
113+
if (typeof rule.canonicalPredicateIri !== "string" || !rule.canonicalPredicateIri.startsWith("https://")) {
114+
errors.push(`canonicalInversePredicates[${index}].canonicalPredicateIri must be an https URI string`);
115+
}
116+
if (typeof rule.inversePredicateIri !== "string" || !rule.inversePredicateIri.startsWith("https://")) {
117+
errors.push(`canonicalInversePredicates[${index}].inversePredicateIri must be an https URI string`);
118+
}
119+
if (rule.strength !== "SHOULD" && rule.strength !== "MAY") {
120+
errors.push(`canonicalInversePredicates[${index}].strength must be SHOULD or MAY`);
121+
}
122+
if (typeof rule.description !== "string" || rule.description.length === 0) {
123+
errors.push(`canonicalInversePredicates[${index}].description must be a non-empty string`);
124+
}
125+
if (typeof rule.path !== "string" || rule.path.length === 0) {
126+
errors.push(`canonicalInversePredicates[${index}].path must be a non-empty string`);
127+
}
128+
}
129+
}
130+
131+
if (errors.length > 0) {
132+
fail(errors);
133+
}
134+
135+
return spec;
136+
}

sdk/javascript/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@fide-work/context-protocol",
3-
"version": "0.0.0-alpha.4",
3+
"version": "0.0.0-alpha.5",
44
"description": "Fide Context Protocol SDK (v0)",
55
"license": "Apache-2.0",
66
"author": "Fide Holdings, Inc. (https://github.com/fide)",
@@ -40,6 +40,7 @@
4040
"access": "public"
4141
},
4242
"scripts": {
43+
"generate:spec": "node ../../scripts/generate-sdk/spec-module.mjs",
4344
"build": "tsc -p tsconfig.json",
4445
"check-types": "tsc --noEmit",
4546
"generate:docs": "node ../../scripts/generate-docs/sdk-reference.mjs",

sdk/javascript/src/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,22 @@ export {
7373
type CanonicalStatementSet,
7474
} from "./statement/index.js";
7575

76+
export {
77+
FCP_STATEMENT_POLICY,
78+
FCP_PREDICATE_ROLE,
79+
FCP_FORBIDDEN_PREDICATES,
80+
FCP_TYPE_ASSERTION_PREDICATES,
81+
FCP_STATEMENT_GUIDE_RULES,
82+
FCP_CANONICAL_INVERSE_PREDICATES,
83+
} from "./spec/index.js";
84+
85+
export type {
86+
FcpPredicateRole,
87+
FcpForbiddenPredicateRule,
88+
FcpStatementGuideRule,
89+
FcpCanonicalInversePredicateRule,
90+
} from "./spec/index.js";
91+
7692
// Policies (used by downstream SDKs/CLIs)
7793
export { enforceStatementPredicateBatchPolicy } from "./statement/policy/enforceStatementPredicateBatchPolicy.js";
7894
export {

sdk/javascript/src/predicate-vocabulary/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/**
22
* Prefix map for expanding standards CURIEs to canonical predicate IRIs.
3+
* Kept in sync with spec/v0/predicate-prefixes.json (prefixes object).
34
*/
45
export const FCP_CURIE_PREFIX_IRIS: Record<string, string> = {
6+
dcterms: "https://purl.org/dc/terms/",
57
schema: "https://schema.org/",
68
rdf: "https://www.w3.org/1999/02/22-rdf-syntax-ns#",
79
rdfs: "https://www.w3.org/2000/01/rdf-schema#",

sdk/javascript/src/predicate-vocabulary/helpers.test.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,25 @@ try {
5353
console.error(" ❌ Error:", error.message);
5454
}
5555

56+
console.log("\n4. Testing Dublin Core Terms prefix...");
57+
checks += 1;
58+
try {
59+
const expanded = expandPredicateReferenceIdentifier("dcterms:replaces");
60+
const compacted = compactPredicateReferenceIdentifier("https://purl.org/dc/terms/replaces");
61+
if (expanded !== "https://purl.org/dc/terms/replaces") {
62+
failures += 1;
63+
console.error(" ❌ Expected dcterms expansion");
64+
} else if (compacted !== "dcterms:replaces") {
65+
failures += 1;
66+
console.error(" ❌ Expected dcterms compaction");
67+
} else {
68+
console.log(" ✅ Expanded and compacted dcterms:replaces");
69+
}
70+
} catch (error) {
71+
failures += 1;
72+
console.error(" ❌ Error:", error.message);
73+
}
74+
5675
if (failures > 0) {
5776
console.error(`\n❌ ${failures} test(s) failed`);
5877
process.exit(1);

sdk/javascript/src/spec/index.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/**
2+
* Generated from `packages/fide-context-protocol/spec/v0/statement-policy.json`.
3+
* Do not edit directly; regenerate from the spec source of truth.
4+
*/
5+
export const FCP_STATEMENT_POLICY = {
6+
"namespaceUrl": "https://fide.work/context-protocol/v0/",
7+
"specVersion": "0",
8+
"specDate": "2026-04-04",
9+
"predicateRole": {
10+
"entityType": "Concept",
11+
"referenceType": "NetworkResource",
12+
"description": "Predicate must use entityType=Concept and referenceType=NetworkResource.",
13+
"path": "/fcp/specification/statements"
14+
},
15+
"forbiddenPredicates": [
16+
{
17+
"id": "fcp.predicate.disallow-schema-identifier",
18+
"predicateIri": "https://schema.org/identifier",
19+
"description": "Entity identifiers are implicit in Fide IDs and reference identifiers; do not add redundant identifier predicates.",
20+
"path": "/fcp/specification/statements"
21+
},
22+
{
23+
"id": "fcp.predicate.disallow-schema-sameAs",
24+
"predicateIri": "https://schema.org/sameAs",
25+
"description": "Use http://www.w3.org/2002/07/owl#sameAs for strict identity assertions; https://schema.org/sameAs is not allowed in FCP statements.",
26+
"path": "/fcp/specification/statements"
27+
}
28+
],
29+
"typeAssertionPredicates": [
30+
"https://www.w3.org/1999/02/22-rdf-syntax-ns#type",
31+
"https://schema.org/additionalType"
32+
],
33+
"guideRules": [
34+
{
35+
"id": "fcp.predicate.concept-network-resource",
36+
"description": "Predicate must use entityType=Concept and referenceType=NetworkResource.",
37+
"path": "/fcp/specification/statements"
38+
},
39+
{
40+
"id": "fcp.predicate.disallow-redundant-type-assertion",
41+
"description": "Do not use rdf:type or schema:additionalType when the object type is already encoded by the subject entity type.",
42+
"path": "/fcp/specification/statements"
43+
},
44+
{
45+
"id": "fcp.links.prefer-has-part-direction",
46+
"description": "For part-whole relationships, prefer schema:hasPart as the canonical authored direction and avoid redundantly authoring schema:isPartOf for the same fact.",
47+
"path": "/fcp/specification/links#inverse-direction"
48+
}
49+
],
50+
"canonicalInversePredicates": [
51+
{
52+
"canonicalPredicateIri": "https://schema.org/hasPart",
53+
"inversePredicateIri": "https://schema.org/isPartOf",
54+
"strength": "SHOULD",
55+
"description": "For part-whole relationships, prefer schema:hasPart as the canonical authored direction.",
56+
"path": "/fcp/specification/links#inverse-direction"
57+
}
58+
]
59+
} as const;
60+
61+
export const FCP_PREDICATE_ROLE = FCP_STATEMENT_POLICY.predicateRole;
62+
export const FCP_FORBIDDEN_PREDICATES = FCP_STATEMENT_POLICY.forbiddenPredicates;
63+
export const FCP_TYPE_ASSERTION_PREDICATES = FCP_STATEMENT_POLICY.typeAssertionPredicates;
64+
export const FCP_STATEMENT_GUIDE_RULES = FCP_STATEMENT_POLICY.guideRules;
65+
export const FCP_CANONICAL_INVERSE_PREDICATES = FCP_STATEMENT_POLICY.canonicalInversePredicates;
66+
67+
export type FcpPredicateRole = typeof FCP_PREDICATE_ROLE;
68+
export type FcpForbiddenPredicateRule = (typeof FCP_FORBIDDEN_PREDICATES)[number];
69+
export type FcpStatementGuideRule = (typeof FCP_STATEMENT_GUIDE_RULES)[number];
70+
export type FcpCanonicalInversePredicateRule = (typeof FCP_CANONICAL_INVERSE_PREDICATES)[number];

0 commit comments

Comments
 (0)