Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 4 additions & 0 deletions src/chains/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import type { ChainReadRelay } from "../daemon/chainRelay.js";
import type { PolicyReader } from "../daemon/chainPolicyReader.js";
import type { OnboardingBackend } from "../onboarding/flow.js";
import type { KeystoreBackend } from "../keystore/backend.js";
import type { PolicyDialect } from "../core/policy/dialect.js";

/** Endpoint + pinned chain id — what every network-touching factory needs. */
export interface ChainWiring {
Expand Down Expand Up @@ -59,6 +60,9 @@ export interface ChainModule {
*/
readonly registryLocatorPattern: RegExp;

/** The chain's policy dialect (#45): evaluation + validation vocabulary. */
readonly dialect: PolicyDialect;

/** Normalize + validate a raw unserialized JSON transaction (INV-014). */
decode(input: unknown, context: ChainContext): DecodedTransaction;

Expand Down
101 changes: 101 additions & 0 deletions src/chains/xpr/dialect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* The XPR policy dialect (issue #45, C.2) — the Antelope vocabulary that used
* to be hardcoded in the core engine and resolver, now behind PolicyDialect:
*
* - match paths: contract / action / authorization.(actor|permission) /
* data.* (single-authorization model, actor → accountIdentifier);
* - assets: the "1.0000 XPR" grammar (parseAsset) and the normalizer's
* data.quantity = { amount, symbol, precision } shape;
* - recipient: data.to;
* - provider: xpr.rpc.tableRow → get_table_rows bounded on the primary key.
*
* The shared patterns come from core/policy/vocabulary.ts — the SAME module
* the web editor imports, so daemon, validator and editor stay in lockstep.
*/

import { AmbiguousValueError } from "../../core/errors.js";
import { parseAsset, parseBareAmount, type AssetAmount } from "../../core/asset.js";
import {
CHAIN_ID_PATTERN,
MATCH_PATH_PATTERN,
SELECT_FIELD_PATTERN,
} from "../../core/policy/vocabulary.js";
import type { DecodedAction } from "../../core/types.js";
import type { DialectRelay, PolicyDialect } from "../../core/policy/dialect.js";
import type { ProviderEvidence, ProviderQuery } from "../../core/policy/engine.js";

export const xprDialect: PolicyDialect = {
matchPathPattern: MATCH_PATH_PATTERN,
selectFieldPattern: SELECT_FIELD_PATTERN,
chainIdPattern: CHAIN_ID_PATTERN,
providerNamespace: "xpr.rpc.tableRow",

resolvePath(action: DecodedAction, path: string): unknown {
if (path === "contract") return action.contract;
if (path === "action") return action.action;
if (path === "authorization.actor") return action.authorization[0]?.accountIdentifier;
if (path === "authorization.permission") return action.authorization[0]?.permission;
if (path.startsWith("data.")) {
let current: unknown = action.data;
for (const segment of path.slice(5).split(".")) {
if (typeof current !== "object" || current === null || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
// Unknown paths are rejected by the schema; reaching this is a logic error.
throw new AmbiguousValueError(`unresolvable match path: ${path}`);
},

/**
* The normalizer produces data.quantity = { amount, symbol, precision };
* a rule with limits applied to an action without it is an ambiguity.
*/
assetOf(action: DecodedAction): AssetAmount {
const quantity = action.data["quantity"];
if (
typeof quantity === "object" &&
quantity !== null &&
typeof (quantity as Record<string, unknown>)["amount"] === "string" &&
typeof (quantity as Record<string, unknown>)["symbol"] === "string" &&
typeof (quantity as Record<string, unknown>)["precision"] === "number"
) {
const q = quantity as { amount: string; symbol: string; precision: number };
const bare = parseBareAmount(q.amount);
if (bare.precision !== q.precision) {
throw new AmbiguousValueError("normalized quantity precision mismatch");
}
return { units: bare.units, symbol: q.symbol, precision: q.precision };
}
throw new AmbiguousValueError("rule has limits but the action carries no comparable asset");
},

recipientOf(action: DecodedAction): string | undefined {
const to = action.data["to"];
return typeof to === "string" ? to : undefined;
},

parseAssetLimit: parseAsset,

async resolveProviderQuery(query: ProviderQuery, relay: DialectRelay): Promise<ProviderEvidence> {
// xpr.rpc.tableRow → a single row bounded on the primary key.
const params = {
code: query.args.contract,
scope: query.args.scope,
table: query.args.table,
lower_bound: query.args.key,
upper_bound: query.args.key,
limit: 1,
json: true,
};
const result = await relay.call("get_table_rows", params);
const rows = (result as { rows?: unknown }).rows;
if (!Array.isArray(rows)) return { ok: false };
const row = rows[0];
if (row === undefined) return { ok: true, found: false, row: null };
if (row === null || typeof row !== "object" || Array.isArray(row)) return { ok: false };
return { ok: true, found: true, row: row as Record<string, unknown> };
},
};
3 changes: 3 additions & 0 deletions src/chains/xpr/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { XprTransactionBroadcaster } from "./broadcaster.js";
import { XprChainReadRelay } from "./relay.js";
import { XprPolicyReader } from "./policyReader.js";
import { XprOnboardingBackend } from "./onboarding.js";
import { xprDialect } from "./dialect.js";
import type { ChainModule, ChainWiring } from "../registry.js";
import type { KeystoreBackend } from "../../keystore/backend.js";

Expand All @@ -31,6 +32,8 @@ export const xprModule: ChainModule = {
chainIdPattern: /^[0-9a-f]{64}$/,
registryLocatorPattern: /^[a-z1-5.]{1,12}$/,

dialect: xprDialect,

decode(input: unknown, context: ChainContext): DecodedTransaction {
return decodeXprTransaction(input, context);
},
Expand Down
7 changes: 4 additions & 3 deletions src/cli/daemonRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,16 @@ export async function startDaemonFromConfig(
const quotas = new QuotaJournal(config.stateDbPath);
const policyReader =
overrides.policyReader ?? chainModule.createPolicyReader(wiring, config.signboxContract);
const policyCache = new PolicyCache(config.stateDbPath, policyReader, {}, overrides.now);
const policyCache = new PolicyCache(config.stateDbPath, policyReader, chainModule.dialect, {}, overrides.now);
const audit = new AuditLog(config.stateDbPath);

const decode = chainModule.decode.bind(chainModule);
const dialect = chainModule.dialect;
const daemon = new SignBoxDaemon(
{ socketPath: config.socketPath, adminSocketPath: config.adminSocketPath },
overrides.now === undefined
? { decode, signer, broadcaster, relay, quotas, policyCache, audit }
: { decode, signer, broadcaster, relay, quotas, policyCache, audit, now: overrides.now },
? { decode, dialect, signer, broadcaster, relay, quotas, policyCache, audit }
: { decode, dialect, signer, broadcaster, relay, quotas, policyCache, audit, now: overrides.now },
);

const wipeAll = (): void => keystore.wipe();
Expand Down
7 changes: 5 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ tx.command("explain")
options.network !== undefined ? { network: options.network } : {},
);
const context = chainContextOf(config);
const dialect = getChain(config.chain).dialect;

// Resolve the policy to evaluate. Default: the on-chain policy is the
// source of truth (INV-004). Override: a local --policy file lets an
Expand All @@ -238,7 +239,7 @@ tx.command("explain")
let source: string;
let meta: Record<string, unknown> = {};
if (options.policy !== undefined) {
policy = validatePolicy(readJsonFile(options.policy));
policy = validatePolicy(readJsonFile(options.policy), dialect);
agentPermission = options.permission ?? "active";
policyVersion = Number(options.policyVersion ?? "1");
source = "local-file";
Expand All @@ -257,7 +258,7 @@ tx.command("explain")
}
// Same integrity gate the daemon cache applies (§8.6): hash +
// canonical JCS + schema. A tampered row is refused, never dry-run.
const verified = verifyStoredPolicy(raw.policyjson, raw.policyhash);
const verified = verifyStoredPolicy(raw.policyjson, raw.policyhash, dialect);
if (!verified.ok) {
fail(`on-chain policy failed integrity check: ${verified.reason}`);
}
Expand All @@ -274,6 +275,7 @@ tx.command("explain")
agentPermission,
chainId: context.chainId,
policyVersion,
dialect,
};
// Resolve any providers (§8.4) the same way the daemon does, so the
// dry-run matches: read them through the same read-only relay.
Expand All @@ -286,6 +288,7 @@ tx.command("explain")
endpoints: config.endpoints,
chainId: config.chainId,
}),
dialect,
)
: undefined;
const result = evaluatePolicy(
Expand Down
74 changes: 74 additions & 0 deletions src/core/policy/dialect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* PolicyDialect (issue #45, C.2) — the chain-specific VOCABULARY of the
* policy engine, extracted behind an interface so the skeleton (deny-first
* control flow, JCS canonicalization, limits model, fail-closed pipeline)
* stays chain-agnostic and byte-identical on every chain.
*
* A dialect owns exactly what varies per chain:
* - which match paths exist and how they resolve against a decoded action
* (XPR: `authorization.actor|permission`, `data.*`);
* - the asset grammar of limit bounds ("1.0000 XPR") and how an action's
* comparable asset is extracted (XPR: `data.quantity`);
* - which field names the recipient for per-recipient quotas (XPR: `data.to`);
* - the deterministic provider namespace and how a resolved query maps to a
* read-only relay call (XPR: `xpr.rpc.tableRow` → `get_table_rows`).
*
* Implementations live in src/chains/<chain>/dialect.ts and are exposed
* through the ChainModule (#44). The engine receives the dialect through the
* EvaluationContext; the validator takes it as a parameter — a policy is
* always validated in ITS chain's dialect (INV-013).
*/

import type { DecodedAction } from "../types.js";
import type { AssetAmount } from "../asset.js";
import type { ProviderEvidence, ProviderQuery } from "./engine.js";

/** Structural view of the daemon's read-only relay (no daemon import here). */
export interface DialectRelay {
call(method: string, params: unknown): Promise<unknown>;
}

export interface PolicyDialect {
/**
* Closed match-path vocabulary, as a schema-embeddable pattern. Drives BOTH
* the validator (propertyNames) and the editor's compiler.
*/
readonly matchPathPattern: string;

/** Provider `select` field names (spec §8.4). */
readonly selectFieldPattern: string;

/** The chain's chain-id format (embedded in the policy document). */
readonly chainIdPattern: string;

/** The deterministic provider namespace this dialect serves. */
readonly providerNamespace: string;

/**
* Resolve a match path against a decoded action. `undefined` means absent;
* an unresolvable path throws AmbiguousValueError (schema-validated paths
* only reach this on logic errors — fail closed).
*/
resolvePath(action: DecodedAction, path: string): unknown;

/**
* The action's comparable asset, for value limits. Throws
* AmbiguousValueError when the action carries none (a rule with limits on
* such an action is a refusal, never a pass).
*/
assetOf(action: DecodedAction): AssetAmount;

/** The action's recipient for per-recipient quotas, if any. */
recipientOf(action: DecodedAction): string | undefined;

/** Parse a limit bound written in the chain's asset grammar. */
parseAssetLimit(text: string): AssetAmount;

/**
* Resolve one provider query through the read-only relay (spec §8.4). The
* generic resolver wraps this with the timeout and the fail-closed catch —
* implementations just map the query to the chain's read call and shape
* the row.
*/
resolveProviderQuery(query: ProviderQuery, relay: DialectRelay): Promise<ProviderEvidence>;
}
Loading
Loading