Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/system-record-managed-ownership.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ name: System-record managed ownership evidence
# 3. Reserved internal state is invisible to enumeration, unserved by
# `hasGraph`, and undeletable by generic mutation including an unscoped
# `deleteByPattern`.
# 4. A live ownership-leased store refuses raw update() and mutating/unknown
# query() programs without changing data, while recognized reads survive.
#
# What it does NOT prove:
# - `pinnedPredecessors` is an INVENTORY, not a set of verdicts. No predecessor
Expand All @@ -39,8 +41,12 @@ on:
- 'packages/agent/**'
- 'packages/cli/**'
- 'packages/core/**'
- 'packages/publisher/**'
- 'packages/query/**'
- 'packages/random-sampling/**'
- 'packages/rdf-utils/**'
- 'packages/storage/**'
- 'scripts/check-managed-store-raw-channels.mjs'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
Expand All @@ -53,8 +59,12 @@ on:
- 'packages/agent/**'
- 'packages/cli/**'
- 'packages/core/**'
- 'packages/publisher/**'
- 'packages/query/**'
- 'packages/random-sampling/**'
- 'packages/rdf-utils/**'
- 'packages/storage/**'
- 'scripts/check-managed-store-raw-channels.mjs'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'tsconfig.base.json'
Expand Down Expand Up @@ -136,6 +146,9 @@ jobs:
- name: Typecheck the ownership gate harness
run: pnpm typecheck:live:system-record-managed-ownership

- name: First-party managed-store raw-channel architecture
run: pnpm check:managed-store-raw-channels
Comment thread
Jurij89 marked this conversation as resolved.

- name: Storage unit conformance
run: |
pnpm --filter @origintrail-official/dkg-storage exec vitest run \
Expand Down
44 changes: 42 additions & 2 deletions devnet/issue-2052-managed-ownership/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
*/

export const MANAGED_OWNERSHIP_RAW_SCHEMA_VERSION =
'dkg-system-record-managed-ownership-result-v1' as const;
'dkg-system-record-managed-ownership-result-v2' as const;
export const MANAGED_OWNERSHIP_VERDICT_SCHEMA_VERSION =
'dkg-system-record-managed-ownership-verdict-v1' as const;
'dkg-system-record-managed-ownership-verdict-v2' as const;

/**
* A pinned manifest entry: an inventory row, NOT a result.
Expand All @@ -36,12 +36,22 @@ export interface PinnedPredecessorV1 {
* `hasGraph`, undeletable by generic mutation including an unscoped
* `deleteByPattern`, and the seed must be intact afterwards.
*/
export interface RawChannelConformanceV1 {
readonly updateRefusalCode: string | null;
readonly queryMutationRefusalCode: string | null;
readonly queryUnknownRefusalCode: string | null;
readonly mutationQuadCount: number;
readonly recognizedReadServed: boolean;
}

export interface CurrentBinaryConformanceV1 {
readonly enumeratedReservedGraphs: readonly string[];
readonly servedReservedGraphs: readonly string[];
readonly deletedReservedGraphsOnCleanup: readonly string[];
readonly seededQuadCount: number;
readonly expectedQuadCount: number;
/** Live proof that an authentic ownership lease closes both raw write routes. */
readonly rawChannels: RawChannelConformanceV1;
readonly failures: readonly string[];
}

Expand Down Expand Up @@ -328,6 +338,36 @@ export function evaluateManagedOwnership(
pass: (raw.currentBinaryConformance?.failures.length ?? 1) === 0,
detail: raw.currentBinaryConformance?.failures.join('; ') || undefined,
},
{
name: 'leasedRawUpdateRefused',
pass: raw.currentBinaryConformance?.rawChannels.updateRefusalCode
=== 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE',
detail: raw.currentBinaryConformance?.rawChannels.updateRefusalCode ?? 'missing refusal',
},
{
name: 'leasedQueryMutationRefused',
pass: raw.currentBinaryConformance?.rawChannels.queryMutationRefusalCode
=== 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE',
detail: raw.currentBinaryConformance?.rawChannels.queryMutationRefusalCode ?? 'missing refusal',
},
{
name: 'leasedQueryUnknownSyntaxRefused',
pass: raw.currentBinaryConformance?.rawChannels.queryUnknownRefusalCode
=== 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE',
detail: raw.currentBinaryConformance?.rawChannels.queryUnknownRefusalCode ?? 'missing refusal',
},
{
name: 'rawChannelsMutatedNoLiveData',
pass: raw.currentBinaryConformance?.rawChannels.mutationQuadCount === 0,
detail: `${raw.currentBinaryConformance?.rawChannels.mutationQuadCount ?? 'missing'} quads`,
},
{
name: 'recognizedReadSurvivesRawChannelContraction',
pass: raw.currentBinaryConformance?.rawChannels.recognizedReadServed === true,
detail: raw.currentBinaryConformance?.rawChannels.recognizedReadServed
? undefined
: 'prefixed ASK did not succeed',
},
];

return checks;
Expand Down
84 changes: 84 additions & 0 deletions devnet/issue-2052-managed-ownership/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
type LiveHandoffMeasurementV1,
type ManagedOwnershipRawResultV1,
type CurrentBinaryConformanceV1,
type RawChannelConformanceV1,
} from './model.js';

const HERE = dirname(fileURLToPath(import.meta.url));
Expand Down Expand Up @@ -148,6 +149,86 @@ async function countQuadsInGraph(endpoint: string, graph: string): Promise<numbe
return Number(json.results?.bindings?.[0]?.c?.value ?? '0');
}

async function captureErrorCode(work: () => Promise<unknown>): Promise<string | null> {
try {
await work();
return null;
} catch (error) {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code: unknown }).code)
: null;
}
}

async function probeLeasedRawChannels(
store: TripleStore,
queryEndpoint: string,
): Promise<{ rawChannels: RawChannelConformanceV1; failures: string[] }> {
const rawChannelGraph = 'urn:dkg:gate:leased-raw-channel';
const refusalProbes = [
{
field: 'updateRefusalCode',
label: 'leased update()',
run: () => store.update
? store.update(
`INSERT DATA { GRAPH <${rawChannelGraph}> { <urn:update> <urn:p> "escaped" } }`,
)
: Promise.resolve(),
},
{
field: 'queryMutationRefusalCode',
label: 'leased query() mutation',
run: () => store.query(
`SELECT ?s WHERE { GRAPH <urn:dkg:gate:read-scope> { ?s ?p ?o } }; ` +
`INSERT DATA { GRAPH <${rawChannelGraph}> { <urn:query> <urn:p> "escaped" } }`,
),
},
{
field: 'queryUnknownRefusalCode',
label: 'leased query() unknown',
run: () => store.query('VALUES ?s { <urn:unknown> }'),
},
] as const;

const refusalCodes = new Map<string, string | null>();
for (const probe of refusalProbes) {
refusalCodes.set(probe.field, await captureErrorCode(probe.run));
}

let recognizedReadServed = false;
try {
const read = await store.query(
'PREFIX dkg: <urn:dkg:gate:>\nASK { dkg:missing dkg:p ?o }',
);
recognizedReadServed = read.type === 'boolean' && read.value === false;
} catch {
recognizedReadServed = false;
}

const rawChannels: RawChannelConformanceV1 = {
updateRefusalCode: refusalCodes.get('updateRefusalCode') ?? null,
queryMutationRefusalCode: refusalCodes.get('queryMutationRefusalCode') ?? null,
queryUnknownRefusalCode: refusalCodes.get('queryUnknownRefusalCode') ?? null,
mutationQuadCount: await countQuadsInGraph(queryEndpoint, rawChannelGraph),
recognizedReadServed,
};
const failures: string[] = [];
const expectedRefusal = 'MANAGED_OXIGRAPH_MUTATION_UNAVAILABLE';
for (const probe of refusalProbes) {
const actual = rawChannels[probe.field];
if (actual !== expectedRefusal) {
failures.push(`${probe.label} refusal code was ${String(actual)}`);
}
}
if (rawChannels.mutationQuadCount !== 0) {
failures.push(`raw channels wrote ${rawChannels.mutationQuadCount} live quad(s)`);
}
if (!rawChannels.recognizedReadServed) {
failures.push('recognized prefixed ASK did not survive raw-channel contraction');
}
return { rawChannels, failures };
}

const pidAlive = (pid: number): boolean => {
try {
process.kill(pid, 0);
Expand Down Expand Up @@ -704,6 +785,8 @@ async function main(): Promise<void> {
// carries no pass/fail field to be misread.
{
const failures: string[] = [];
const rawProbe = await probeLeasedRawChannels(full, server.queryEndpoint);
failures.push(...rawProbe.failures);

// The deletion probe below is destructive, so seed immediately before it.
// This is also why the old per-entry loop could not work: the first row's
Expand Down Expand Up @@ -831,6 +914,7 @@ async function main(): Promise<void> {
deletedReservedGraphsOnCleanup: deleted,
seededQuadCount,
expectedQuadCount: fixture.expectedQuadCount,
rawChannels: rawProbe.rawChannels,
failures,
};
}
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"test:live:system-record-managed-ownership:generate": "pnpm -r --filter @origintrail-official/dkg... --filter '!@origintrail-official/dkg-evm-module' run build && node --import tsx devnet/issue-2052-managed-ownership/run.ts",
"test:live:system-record-managed-ownership:verify": "node --import tsx devnet/issue-2052-managed-ownership/verify.ts",
"typecheck:live:system-record-managed-ownership": "pnpm --filter @devnet/issue-2052-managed-ownership typecheck",
"check:managed-store-raw-channels": "node --import tsx scripts/check-managed-store-raw-channels.mjs",
"test:watch": "vitest --config vitest.config.ts",
"test:coverage": "turbo test:coverage",
"bench": "pnpm --filter @origintrail-official/dkg-storage build && esbench --config esbench.config.mjs",
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/generic-sql-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,7 @@ async function createMssqlClient(
}
request.input(name, value);
}
// dkg-raw-channel-non-store: optional mssql Request, not an RDF TripleStore.
Comment thread
Jurij89 marked this conversation as resolved.
const result = await request.query(sql);
return result.recordset ?? [];
},
Expand Down
108 changes: 104 additions & 4 deletions packages/core/src/sparql-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ const OPERATION_AT_START = new RegExp(
`\\s*(${[...SPARQL_READ_ONLY_OPERATIONS, ...SPARQL_UPDATE_OPERATIONS].join('|')})\\b`,
'iy',
);
const MUTATING_PATTERN = new RegExp(
const MUTATING_TOKEN_PATTERN = new RegExp(
`\\b(${SPARQL_MUTATING_KEYWORDS.join('|')})\\b`,
'i',
'ig',
);
const UPDATE_OPERATION_SET = new Set<string>(SPARQL_UPDATE_OPERATIONS);
const READ_ONLY_OPERATION_SET = new Set<string>(SPARQL_READ_ONLY_OPERATIONS);
Expand Down Expand Up @@ -152,16 +152,116 @@ function classifySparqlOperationForm(form: SparqlDetectedOperation): SparqlOpera
return { kind: 'unknown' };
}

function isSparqlNameAdjacent(ch: string | undefined): boolean {
Comment thread
Jurij89 marked this conversation as resolved.
return ch !== undefined && (
isSparqlWordContinuation(ch)
|| /[\p{L}\p{N}\p{M}?$:@.-]/u.test(ch)
);
}

// SPARQL 1.1 PN_LOCAL_ESC. Escaped punctuation remains part of a prefixed
// local name, so an update word beside it is not an executable keyword.
const PN_LOCAL_ESC_CHAR = /[_~.\-!$&'()*+,;=/?#@%]/u;

function isEscapedPnLocalCharAt(src: string, index: number): boolean {
return index >= 1
&& src[index - 1] === '\\'
&& PN_LOCAL_ESC_CHAR.test(src[index] ?? '');
}

function isSparqlNameAdjacentBefore(src: string, index: number): boolean {
return isSparqlNameAdjacent(src[index - 1]) || isEscapedPnLocalCharAt(src, index - 1);
}

function isSparqlNameAdjacentAfter(src: string, index: number): boolean {
return isSparqlNameAdjacent(src[index])
|| (src[index] === '\\' && PN_LOCAL_ESC_CHAR.test(src[index + 1] ?? ''));
}

function isSparqlWordStart(ch: string | undefined): boolean {
return !!ch && (
(ch >= 'A' && ch <= 'Z')
|| (ch >= 'a' && ch <= 'z')
|| ch === '_'
);
}

/** Shared ASCII keyword boundary used by admission and query rewriting. */
export function isSparqlWordContinuation(ch: string | undefined): ch is string {
return isSparqlWordStart(ch) || (!!ch && ch >= '0' && ch <= '9');
}

export function isSparqlKeywordStart(src: string, idx: number): boolean {
const ch = src[idx];
if (!isSparqlWordStart(ch)) return false;
const prev = idx > 0 ? src[idx - 1] : '';
return !prev || (
!isSparqlWordContinuation(prev)
&& prev !== '?'
&& prev !== '$'
&& prev !== ':'
&& prev !== '#'
);
}

export function isSparqlKeyword(
src: string,
start: number,
end: number,
keyword: string,
): boolean {
const next = src[end];
return src.slice(start, end).toUpperCase() === keyword
&& next !== ':'
&& next !== '-'
&& next !== '.';
}

/**
* Find an executable update keyword without mistaking it for a legal SPARQL
* name such as `?delete`, `insert:p`, `ex:drop`, or `"x"@add`.
* Literals, comments, and IRI references have already been blanked by the
* caller. The remaining adjacency check distinguishes standalone grammar
* tokens from variable, prefixed-name, language-tag, and identifier text.
*/
function findMutatingKeyword(stripped: string): string | null {
MUTATING_TOKEN_PATTERN.lastIndex = 0;
for (;;) {
const match = MUTATING_TOKEN_PATTERN.exec(stripped);
if (!match) return null;
const start = match.index;
const end = start + match[0].length;
if (
!isSparqlNameAdjacentBefore(stripped, start)
&& !isSparqlNameAdjacentAfter(stripped, end)
) {
return match[1] ?? null;
}
}
}

export function analyzeSparqlOperation(sparql: string): SparqlOperationAnalysis {
const stripped = stripSparqlLiteralsAndComments(sparql);
const form = detectSparqlOperationFormFromStripped(stripped);
const match = MUTATING_PATTERN.exec(stripped);
return {
operation: classifySparqlOperationForm(form),
mutatingKeyword: match?.[1] ?? null,
mutatingKeyword: findMutatingKeyword(stripped),
};
}

/**
* Canonical read-only admission policy for already-analyzed SPARQL.
* A recognized read form is safe only when no standalone executable update
* keyword remains elsewhere in the program (for example after a semicolon).
*/
export function recognizedReadOnlySparqlForm(
analysis: SparqlOperationAnalysis,
): SparqlReadOnlyOperation | null {
return analysis.operation.kind === 'read' && analysis.mutatingKeyword === null
? analysis.operation.form
: null;
}

export function classifySparqlOperation(sparql: string): SparqlOperationClassification {
return analyzeSparqlOperation(sparql).operation;
}
Expand Down
Loading
Loading