Skip directly to article

Current guidance · concepts baseline 0.2.0-preview.1

Grounding and candidates

How is intent grounded?

PREVIEW ACCESS

Use the selected preview versions for this example. Find your packages, or try the package-free Lumber guided tour.

Grounding connects a proposed action to the exact record, resource, account, item, document, service, or other target that the application intends to change. A candidate is a bounded possible match; the referent is the exact identity selected for later stages. A candidate match is not authorization or proof of an effect.

Where candidates come from

A proposal may originate in a web application, copilot, API, workflow, webhook, messaging interface, voice interface, or another agent or framework. The host may query or receive bounded candidates from a service catalog, CRM, database, document index, inventory system, identity directory, or other named source.

The host supplies candidate records, their exact identifiers, provenance, and snapshot or revision. Core associates the proposal with the selected candidate and that provenance; it does not invent the list or silently choose among ambiguous records.

Ambiguity and exact selection

Grounding follows trusted-context admission and precedes authorization. If candidates are missing, stale, foreign, contradictory, or ambiguous, the application asks for clarification or returns NOT_DISPATCHED. A model ranking can help an application choose what to ask about, but confidence is not evidence for selecting a production record or another consequential target.

Example: targeting the payments API

A developer or operations copilot proposes restarting “the payments API.” The organization’s service catalog contains separate production and staging services with similar names. The backend supplies bounded candidates with their environment, tenant, and service identifiers.

Grounding must bind the proposal to one exact service record before authorization. If both records remain plausible, the application asks for clarification or returns NOT_DISPATCHED; it does not let a confident model ranking select production. The Core package does not operate infrastructure or restart the service.

Normalization limits

@answerup/actions@0.2.0-preview.8 defines one exact admission contract before authorization or dispatch:

  • grounded.scope must equal TrustedContext.tenant.securityDomainRef exactly. This security-domain binding is different from TrustedContext.authorization.scopeRef, which is the separate policy scope used by authorization.
  • Every resolving candidate requires its own non-empty string attributes.resource data property. A prototype property or another attribute is not a substitute.
  • The normalized attributes.resource must match grounded.subject. Normalization is Unicode NFKC, trim, whitespace collapse to one space, and lowercase.
  • Exactly one candidate must match after normalization. Zero or multiple matches fail closed.

Normalization can compare only those approved formatting differences. It cannot erase meaningful differences such as production versus staging, tenant, currency, timezone, product size, or resource identity. It cannot invent a missing identifier, prove ownership, or establish that an external effect happened.

Bounded grounding diagnostics

ConditionResult kindLifecycle incident code
Scope is not the trusted tenant security domainDENIEDGROUNDING_SCOPE_MISMATCH
A candidate lacks its own non-empty string discriminatorNEEDS_CLARIFICATIONGROUNDING_RESOURCE_DISCRIMINATOR_MISSING
No normalized candidate discriminator matches the subjectNEEDS_CLARIFICATIONGROUNDING_RESOURCE_DISCRIMINATOR_MISMATCH
More than one normalized discriminator matchesNEEDS_CLARIFICATIONGROUNDING_RESOURCE_DISCRIMINATION_AMBIGUOUS

All four outcomes have NOT_DISPATCHED effect truth, zero authorization calls, zero provider dispatches, and zero mutations. Their incidents expose only phase, closed code, and not_invoked dispatch boundary—never a raw candidate, evidence value, tenant identifier, exception, credential, provider payload, or provider material.

A missing, empty, non-string, accessor-backed, or otherwise malformed grounded.subject is not classified as a resource-discriminator failure. It retains the generic privacy-safe malformed-grounding result, INTEGRATION_FAILURE with PROJECTION_UNAVAILABLE, before dispatch.

Executable failure matrix

The following package-root-only program runs all four bounded failures and asserts zero authorization, dispatch, and mutation. Save it as lumber-grounding-diagnostics.mts, then compile it against the accepted tuple with the same documented package.json, tsconfig.json, pinned TypeScript 5.8.3, pinned @types/node 22.15.30, and package-local npm run build shown in the executable lumber quickstart.

import { createProtectedActionRuntime, defineProtectedAction, type CandidateReferent, type SecurityDomainRef, type TrustedContext } from '@answerup/actions';

const context = {
  tenant: { tenantRef: 'tenant:demo', securityDomainRef: 'security-domain:demo' },
  actor: { actorRef: 'actor:demo', assurance: 'host-session' },
  authorization: { scopeRef: 'policy-scope:inventory', policyRevision: 'policy:7' },
  provider: { accountRef: 'inventory:fixture' },
  delivery: { deliveryId: 'delivery:diagnostics', source: 'test' },
  hostInvocation: { invocationId: 'invocation:diagnostics' },
} as TrustedContext;

const subject = 'lumber: cedar-8ft-gradea';
type Case = { scope: string; candidates: Array<{ referent: string; source: 'authoritative'; attributes: Record<string, string>; evidenceRefs: string[] }> };
const cases: Record<string, Case> = {
  wrongScope: { scope: context.authorization.scopeRef, candidates: [{ referent: 'one', source: 'authoritative', attributes: { resource: subject }, evidenceRefs: [] }] },
  missing: { scope: context.tenant.securityDomainRef, candidates: [{ referent: 'one', source: 'authoritative', attributes: {}, evidenceRefs: [] }] },
  mismatch: { scope: context.tenant.securityDomainRef, candidates: [{ referent: 'one', source: 'authoritative', attributes: { resource: 'lumber:pine' }, evidenceRefs: [] }] },
  ambiguous: { scope: context.tenant.securityDomainRef, candidates: [
    { referent: 'one', source: 'authoritative', attributes: { resource: ' LUMBER:\tCEDAR-8FT-GRADEA ' }, evidenceRefs: [] },
    { referent: 'two', source: 'authoritative', attributes: { resource: 'lumber: cedar-8ft-gradea' }, evidenceRefs: [] },
  ] },
};

const expected: Record<string, [string, string]> = {
  wrongScope: ['DENIED', 'GROUNDING_SCOPE_MISMATCH'],
  missing: ['NEEDS_CLARIFICATION', 'GROUNDING_RESOURCE_DISCRIMINATOR_MISSING'],
  mismatch: ['NEEDS_CLARIFICATION', 'GROUNDING_RESOURCE_DISCRIMINATOR_MISMATCH'],
  ambiguous: ['NEEDS_CLARIFICATION', 'GROUNDING_RESOURCE_DISCRIMINATION_AMBIGUOUS'],
};

for (const [name, fixture] of Object.entries(cases)) {
  let authorizationCalls = 0; let dispatches = 0; let mutations = 0;
  const runtime = createProtectedActionRuntime();
  runtime.register(defineProtectedAction({
    name: `grounding_${name}`, version: '1',
    validateProposal: () => ({ valid: true, input: { quantity: 20 } }),
    // Casts are isolated to this negative test matrix; production candidates use the declared shape.
    ground: () => ({ kind: 'GROUNDED', grounded: { subject }, scope: fixture.scope as SecurityDomainRef, subject, requestedEffect: { quantity: 20 }, candidates: fixture.candidates as unknown as CandidateReferent[], observationRef: 'catalog:12' }),
    authorize: () => { authorizationCalls += 1; return 'PERMIT'; },
    provider: { async dispatch() { dispatches += 1; mutations += 1; return { kind: 'ACKNOWLEDGED' }; } },
    verifier: { async verify() { return { kind: 'PRESENT', observationRef: 'inventory:13' }; } },
  }));
  const result = await runtime.run({ action: `grounding_${name}`, input: { quantity: 20 }, context });
  const [kind, code] = expected[name];
  if (result.kind !== kind || result.incidents[0]?.code !== code || authorizationCalls !== 0 || dispatches !== 0 || mutations !== 0) throw new Error(`${name} failed closed incorrectly`);
  console.log(JSON.stringify({ name, kind: result.kind, code, authorizationCalls, dispatches, mutations }));
}

Failure and ownership

The host owns source queries, candidate limits, provenance, snapshot freshness, and clarification UX. It also decides how to handle a candidate that becomes stale between preparation and dispatch. Core preserves the bounded candidate and referent association and keeps ambiguity from becoming an implicit choice. Grounding does not prove authorization or effect truth; authorization still applies only to the exact grounded operation, and independent authoritative verification determines any external effect.