Architecture

Suggestion is not execution: the causal boundary in agentic systems

17 min

Author: Paulinho Giovannini

There is a silent failure that systems with AI integration can introduce before anyone realizes it exists.

It does not show up as a runtime bug. It does not throw an exception. It does not break a test. It appears months later, when someone tries to audit a historical execution and discovers that the snapshot contains a field that should never have had causal authority: an actor identifier, a validation timestamp, a reference to the agent that suggested the action.

Replay fails — or worse, it starts depending on fields that should never have influenced execution.

Not because the logic changed. Because the boundary between suggestion and execution was never formally defined.

Today, this problem usually appears when an LLM is introduced into an action flow. But it is not an LLM-specific problem.

It appears whenever a layer proposes, interprets, or recommends actions before another layer must execute causally and remain historically accountable for the result it produced.

The correct boundary is simple, but it must be structural: the cognitive layer proposes; the Application Service resolves admissibility, authorization, idempotency, and contractual translation; the engine executes only domain causality; and the ledger records identity.


The problem that grows slowly

When an agent enters a system's action flow — whether an LLM, an automated workflow, a recommendation system, or any component that proposes actions before causal execution — the integration feels natural.

The agent produces a suggestion.
The system validates it.
The effect happens.
The result is saved.

The link that appears to protect the system is validation. If the agent output passes a rigorous check before reaching the engine, the system appears safe.

It is not.

The problem is not only validation quality. It is which types are allowed to cross which boundary, and what that permission forces the engine to know.

This distinction matters because the problem here is not AI safety, prompt quality, or hallucination detection. Those problems exist, but they belong to a different surface.

The problem here is causal integrity.


Contagion by proximity

A typical cognitive output carries more than action intent.

It may carry identity, justification, origin, reasoning context, agent reference, validation timestamp, confidence score, conversation history, idempotency key, resolved operational capability/authorization, or any other metadata useful for tracing the proposal.

Those fields can be legitimate. Some are essential for audit. Others are useful for investigation. The error happens when they cross the wrong boundary.

The reason for this boundary is not defensive validation. It is distinguishing what caused the result from who requested, suggested, or approved the result.

When the type that represents an agent proposal can reach the engine, even after validation, three things begin to happen.

The engine starts knowing the concept of a proposal. It should not. The engine executes causal commands. Proposals are operational context from whoever decided to invoke the engine. If the cognitive model changes, the engine should not need to change with it.

Operational metadata enters the causal surface. proposed_by, actor_id, validated_at, and proposal_reason_ref are legitimate fields in the cognitive, operational, or auditable world. Inside the engine, they become non-causal variables with the power to contaminate replay.

The historical snapshot loses causal purity. Two causally equivalent commands can produce distinct snapshots only because they were suggested by different agents or validated at different times.

This is the worst kind of error: the system keeps working until the day it has to prove what it did.

The question therefore stops being:

Was the agent output validated?

And becomes:

Can the engine still describe its own causality without knowing the cognitive model?


Validation is not a boundary

This is the most common mistake.

Teams treat validation as if it were an architectural boundary. It is not.

Validation answers whether content is acceptable.
A boundary answers whether that content is allowed to circulate to a given place.

An agent payload can be valid and still be forbidden from entering the engine. A field can be true and still be non-causal. A timestamp can be useful for audit and, at the same time, destructive if it enters snapshot computation.

The boundary does not exist only to block invalid data.

It exists to prevent data from the wrong surface from acquiring causal authority.


The solutions that do not solve it

This problem usually produces four responses. All of them seem reasonable. All of them preserve some form of validation. And all of them fail at the same point: they allow the wrong model to remain too close to the causal surface.

Unified type with is_proposed

The first response is often to create a single action type with a flag:

type Action = {
	action_type: string;
	target: { entity: string; id: string };
	payload: Record<string, unknown>;
	is_proposed: boolean;
	proposed_by?: string;
	validated_at?: string;
};

It looks elegant. One type. One pipeline. One field indicating origin.

In practice, the engine can still receive the full type — including proposed_by, validated_at, and any future field from the cognitive model. The flag does not create a boundary. It only puts proposal and execution inside the same circulable contract.

Worse: the engine now knows the concept of a proposal, a detail from the cognitive surface that should not exist in the causal vocabulary of execution.

This is conceptual contamination. The problem is not that is_proposed exists. The problem is that causal and non-causal fields coexist in a type that can reach the causal surface. The compiler does not report the error because the error has been encoded into the model.

Runtime validation inside the engine

Another response is to reject cognitive or operational fields inside the engine itself.

If it receives something with proposed_by, reject it.

This inverts responsibility.

To reject a cognitive, operational, or auditable field, the engine must know that vocabulary. The contagion has already happened. The boundary stopped being structural and became a defensive runtime inspection.

This kind of error appears late. It appears in production. It appears after the wrong type has already crossed the layer that should have been inviolable.

Conversion in the controller

Controllers look like a convenient place to convert a suggestion into a command.

They are not.

Controllers know transport, superficial validation, parsing, authenticated-context extraction, and request context. They should not resolve operational capability/authorization, idempotency, per-resource authorization, domain semantics, or execution contracts.

Putting the conversion there produces one of two things:

  • a controller bloated with application and domain logic;

  • or a shallow conversion that appears safe because it transforms shape, but does not resolve authority.

Both degrade the architecture.

Generic payload all the way to the engine

The fourth response is keeping the payload open for too long:

payload: Record<string, unknown>;

The system gains flexibility and loses almost everything that made the boundary verifiable: rejection of unknown fields, exhaustiveness checking, traceability of new action types, and the guarantee that each payload was interpreted by an explicit resolver or policy.

An open payload is tolerable at the cognitive edge, because it does not yet have causal authority. It becomes dangerous when it crosses the boundary and reaches the engine as if it were a domain command.

This is the point teams usually miss: the problem is not that Record<string, unknown> exists. The problem is that it reaches the wrong place.


Four artifacts, three surfaces

The correct solution separates four artifacts distributed across three authority surfaces:

  • ProposedAction: proposal on the cognitive surface;

  • DomainCommand: typed causal command;

  • EngineExecutionContext: technical causal execution context;

  • AuditContext: identity, authorization, idempotency, and operational traceability.

Even when two of them appear to represent "the same action," they do not have the same right to circulate.

ProposedAction: cognitive output

ProposedAction is what the agent delivers.

It belongs to the cognitive surface: proposal, intent, justification, origin, and context.

interface ProposedAction {
	action_type: string;
	target: { entity: string; id: string };
	payload: Record<string, unknown>;

	proposal_id: string;
	proposed_by: string;
	proposal_reason_ref: string;
	confidence?: number;
}

action_type, target, and payload may be open here because they do not yet have causal authority. They are raw material for mediation.

The rule is absolute:

ProposedAction never enters the engine.

Not through a direct import.
Not through an indirect export in an aggregator file.
Not through a path alias.
Not through temporary convenience.

The engine must not know this type.

DomainCommand: causal input to the engine

DomainCommand is what the engine receives.

No identity.
No cognitive justification.
No reference to the agent.
No audit metadata.

It is a discriminated union by action_type, with a strictly typed payload per action.

type EntityTarget = {
	entity: 'entity';
	id: string;
};

type EntityExecuteCommand = {
	action_type: 'entity.execute';
	target: EntityTarget;
	payload: {
		operation: 'ACTIVATE' | 'SUSPEND';
	};
};

type EntityUpgradeCommand = {
	action_type: 'entity.upgrade';
	target: EntityTarget;
	payload: {
		target_model_version: string;
		effective_at: string;
		domain_upgrade_reason: 'CONTRACT_MIGRATION' | 'REGULATORY_CHANGE' | 'MODEL_DEPRECATION';
	};
};

type DomainCommand = EntityExecuteCommand | EntityUpgradeCommand;

function assertNeverDomainCommand(command: never): never {
	throw new Error(`Unhandled domain command: ${JSON.stringify(command)}`);
}

The advantage of a discriminated union is not aesthetics. It is verifiable restriction.

When DomainCommand is a closed union, strict is active, and the switch calls assertNeverDomainCommand(command) in the default branch, unhandled new commands fail compilation. That guarantee disappears with any, casts, generic payloads, generic fallbacks, or widening action_type to string.

This matters more than it seems. Systems with agents tend to gain new action types quickly. Without exhaustiveness checking, each new action becomes a risk of an implicit fallback path.

A field like domain_upgrade_reason belongs in DomainCommand only when it is causal: that is, when the domain uses that code to select a rule, contract, permitted transition, or effect. If it only explains why the action was proposed or accepted, it belongs in the ledger, audit, or explainability layer, not in the causal command.

EngineExecutionContext: technical causal context

The engine can also receive pure technical context.

But that context must obey one rule: only what can affect causal execution or parameterize the engine without bringing live identity may enter it.

interface EngineExecutionContext {
	mode: 'NORMATIVE' | 'EXPLORATORY';
	contract_versions: {
		engine: string;
		ruleset: string;
	};
}

Here, mode is not duplicated inside DomainCommand.payload. It belongs to technical causal context because it parameterizes engine execution. If the mode selects a contract, thresholds, defaults, rule versions, or execution class, it must enter the snapshot; but it remains separate from the domain command so the system does not create two sources of truth.

An exploratory mode can exist, but it cannot be a shortcut to normative execution with relaxed invariants. EXPLORATORY must select an explicit exploratory contract, produce a non-authoritative artifact, and not update normative state. Otherwise, mode stops being causal context and becomes a side door for implicit behavior.

Never:

interface ForbiddenEngineExecutionContext {
	actor_id: string;
	user_id: string;
	org_id: string;
	auth_context: unknown;
	jwt: string;
}

The normative rule is simple:

If a field changes the causal result — selects a contract, changes thresholds, alters defaults, defines effective time, chooses a rule version, or selects an explicit execution class — it must enter the snapshot and participate in the causal identity of the historical snapshot.

If it is only observational — log verbosity, correlation identifier, metric, trace — it must not influence any causal branch.

The distinction is not preference. It is a consequence for replay.

AuditContext: identity and operational traceability

Identity does not disappear. It simply does not enter the engine.

AuditContext belongs to the ledger.

interface AuditContext {
	command_id: string;
	capability_id: string;
	actor_id: string;
	idempotency_key: string;
	correlation_id?: string;
	confirmation_evidence?: {
		confirmed_at: string;
		mechanism: 'UI_CONFIRMATION' | 'SIGNED_APPROVAL' | 'OPERATOR_POLICY';
	};
}

It answers different questions:

  • who requested it;

  • under which operational capability/authorization;

  • with which idempotency key;

  • in which operational correlation;

  • which auditable command was admitted.

In this text, capability means a resolved operational authorization: the concrete ability to execute a specific action on a specific resource under a known policy.

Those questions matter. They are just not questions the engine needs to answer to compute the result.

The error is not recording identity. The error is giving identity causal authority.

The idempotency_key can determine whether the Application Service executes a new command or returns an already recorded execution. That affects application orchestration, not the engine calculation. This is why it belongs in the ledger and orchestration policy, not in the engine's causal input. The same logic applies to capability_id: it can admit or block execution, but it must not shape the causal command unless that effect appears explicitly in the command or causal context.


The Application Service as mediation boundary

The conversion from ProposedAction into DomainCommand + EngineExecutionContext + AuditContext happens in exactly one place.

Not in the controller.
Not in the engine.
In the Application Service.

const decision = await applicationPolicy.resolve({
	proposal,
	actor_id: actorId,
	capability_id: resolvedCapability,
	idempotency_key: idempotencyKey,
	command_id: commandId,
});

const command = commandResolver.toDomainCommand(proposal, {
	effective_at: decision.effective_at,
});

const execution_context = executionContextFactory.create({
	mode: decision.execution_mode,
	contract_versions: decision.contract_versions,
});

const execution_frame = buildExecutionFrame({
	command,
	causal_context: execution_context,
	causal_inputs: decision.causal_inputs,
});

const result = await engine.execute(execution_frame);

await auditService.record(
	buildAuditPayload({
		audit_context: auditContextFactory.create(decision),
		proposal_digest: digest(proposal),
		command_digest: digest(command),
		execution_context_digest: digest(execution_context),
		causal_inputs_digest: digest(execution_frame.causal_inputs),
		execution_frame_digest: digest(execution_frame),
		causal_result_digest: digest(result.causal_output),
	}),
	trx
);

All digests must be computed over a canonical representation: stable field ordering, temporal normalization, defined numeric precision, no observational fields, and an explicit serialization contract version.

The result digest must cover only the engine's causal output. Logs, metrics, observational timestamps, explanatory warnings, and traces belong in the ledger or explainability layer.

The conversion in toDomainCommand() is not generic.

It is a switch(action_type) with a strict schema per action type, whole-envelope proposal validation, target validation, rejection of unknown fields, and closed failure. Cognitive fields may be present in the original proposal. They do not pass into the causal command.

const ProposedActionEnvelopeSchema = z.strictObject({
	action_type: z.string(),
	target: z.unknown(),
	payload: z.record(z.string(), z.unknown()),
	proposal_id: z.string(),
	proposed_by: z.string(),
	proposal_reason_ref: z.string(),
	confidence: z.number().optional(),
});

const EntityTargetSchema = z.strictObject({
	entity: z.literal('entity'),
	id: EntityIdSchema,
});

const EntityExecuteSchema = z.strictObject({
	operation: z.enum(['ACTIVATE', 'SUSPEND']),
});

const EntityUpgradeSchema = z.strictObject({
	target_model_version: z.string(),
	domain_upgrade_reason: z.enum(['CONTRACT_MIGRATION', 'REGULATORY_CHANGE', 'MODEL_DEPRECATION']),
});

function toDomainCommand(proposal: unknown, ctx: CommandResolutionContext): DomainCommand {
	const parsedProposal = ProposedActionEnvelopeSchema.parse(proposal);
	const target = EntityTargetSchema.parse(parsedProposal.target);

	switch (parsedProposal.action_type) {
		case 'entity.execute': {
			const payload = EntityExecuteSchema.parse(parsedProposal.payload);

			return {
				action_type: 'entity.execute',
				target,
				payload,
			};
		}

		case 'entity.upgrade': {
			const payload = EntityUpgradeSchema.parse(parsedProposal.payload);

			return {
				action_type: 'entity.upgrade',
				target,
				payload: {
					...payload,
					effective_at: ctx.effective_at,
				},
			};
		}

		default:
			return rejectUnsupportedActionType(parsedProposal.action_type);
	}
}

function rejectUnsupportedActionType(actionType: string): never {
	throw new Error(`Unsupported action_type: ${actionType}`);
}

parse() is acceptable only if the schema is strict. Sanitizing by removing unknown fields is not equivalent to rejecting unknown fields, because it can mask an attempt to cross the boundary.

A strict schema guarantees shape; it does not replace domain policy, authorization, idempotency, or contract resolution.

The cognitive envelope accepts action_type as a string because that edge does not yet have causal authority. Promotion to command happens only for the cases supported by the switch; everything else fails closed.

Resolving contract versions is also part of the boundary. Resolution may depend on current state at decision time, but the resolved result must be materialized before execution, persisted in the snapshot, and used during replay. Replay must never recalculate versions from the current state of a mutable record.

The same is true for domain state. A target.id identifies the target, but it does not freeze the state used in execution. If the engine loads mutable state from that identifier, the historical snapshot starts depending on the current state of the system. An auditable execution must record the causal state used by the engine: a materialized snapshot or historically resolvable reference, accompanied by an integrity digest.

Causal temporal inputs must be canonicalized before entering the command: explicit timezone, normalized format, defined precision, and no dependency on the engine's local clock.

The engine executes over a causal execution frame, not over a translated proposal plus loose context.

interface VersionedCausalInputs {
	input_contract_version: string;

	state_snapshot_ref: string;
	state_digest: string;

	ruleset_ref: string;
	ruleset_digest: string;
}

interface EngineExecutionFrame {
	command: DomainCommand;
	causal_context: EngineExecutionContext;
	causal_inputs: VersionedCausalInputs;
}

The digest verifies integrity. The historically resolvable reference allows the causal artifact to be recovered. Without both, replay becomes an assumption.

The engine signature remains singular:

interface Engine {
	execute(frame: EngineExecutionFrame): Promise<EngineResult>;
}

Creating an overloaded signature that accepts AuthContext, ActorContext, JWT, RequestContext, or any other form of live identity as a temporary convenience is an architectural violation.

There is no documented shortcut.


The resolver must also be contained

Moving conversion to the Application Service does not make the resolver a free zone.

The Application Service may read cognitive, operational, and auditable context to record audit, resolve operational capability/authorization, apply idempotency, or reject a proposal. What it cannot do is let those fields decide the causal shape of the command.

If proposed_by, proposal_reason_ref, confidence, validated_at, actor_id, or capability_id alter the DomainCommand, then contamination merely moved elsewhere.

The rule is simple: if a field should affect execution, it must be promoted to an explicit, typed, validated causal input and persisted in the snapshot. Otherwise, it may influence audit, rejection, or operational routing, never the causal command.

This is the part usually missing in agent integrations. The team creates a good boundary around the engine, but allows mediation to make causal decisions from cognitive or operational metadata. The boundary looks clean from the outside and is already contaminated from within.


Timestamps are not all the same

validated_at looks harmless. It is frequently not causal. In general, it describes when a proposal was validated by an application layer or by a human.

That timestamp belongs in audit.

But this does not mean time is never causal.

An effective_at, upgraded_at, or valid_from can be part of a domain rule. If the domain uses that instant to choose a contract, apply a rule, calculate an effect, or determine validity, it is causal. In that case, it must be in the command, snapshot, and causal digest/hash.

The difference is not in the primitive type. Both can be ISO 8601 strings. The difference is in the question the field answers.

validated_at answers:

when was the proposal validated?

effective_at answers:

which instant participates in the executed rule?

One is historical-operational. The other may be causal.

Confusing the two is one of the most discreet ways to corrupt replay.

The engine also must not read current time, randomness, or invisible environmental configuration. If time, seed, rule version, or configuration changes the result, it must enter as an explicit causal input. Otherwise, two executions with the same command can diverge without the snapshot explaining why.


Executable guarantees that do not depend on collective memory

Separating the artifacts solves the problem conceptually. But architecture without executable guarantees is intention.

The boundary must break compilation when violated.

The first guarantee must be structural: the engine cannot import cognitive types.

engine/src
  may depend on:
    - domain-command
    - engine-context
    - pure-domain-types

  must not depend on:
    - cognitive
    - auth
    - rbac
    - transport
    - application-service

A dependency-graph barrier must prevent any file inside the engine from importing or exporting modules from forbidden paths.

The denylist of tokens comes next. It is not the boundary. It is a sensor.

[
	{ "pattern": "ProposedAction", "code": "COGNITIVE_TYPE_IN_ENGINE" },
	{ "pattern": "proposal_reason_ref", "code": "COGNITIVE_FIELD_IN_ENGINE" },
	{ "pattern": "proposed_by", "code": "COGNITIVE_FIELD_IN_ENGINE" },
	{ "pattern": "validated_at", "code": "NON_CAUSAL_TIMESTAMP_IN_ENGINE" },
	{ "pattern": "applied_by", "code": "IDENTITY_IN_ENGINE" },
	{ "pattern": "AuthContext", "code": "IDENTITY_IN_ENGINE" },
	{ "pattern": "JWT", "code": "IDENTITY_IN_ENGINE" },
	{ "pattern": "RBAC", "code": "IDENTITY_IN_ENGINE" }
]

It catches obvious violations and makes the error legible. But it does not replace AST analysis, dependency-graph rules, or boundary rules at compilation time.

The minimum guarantee must cover four barriers:

  1. dependency-graph boundary: the engine does not import cognitive, auth, transport, application, rbac, billing, or identity;

  2. public API boundary: the engine exports only Engine, DomainCommand, EngineExecutionContext, EngineResult, and pure types;

  3. forbidden symbols by AST or type: ProposedAction, AuthContext, ActorContext, JWT, RequestContext, confidence, proposed_by, validated_at, and equivalents do not enter the engine;

  4. contract tests and golden replay tests: historical snapshots are replayable without ledger, actor, cognitive proposal, or the original request.

A simple grep can catch direct imports:

grep -RIn --include='*.ts' "from .*cognitive" engine/src && exit 1 || true

But it does not cover aggregator exports, path aliases, or indirect exports. Those cases require dependency-graph guarantees.

Transparency about this limit is part of the model. Whatever is not yet automated must appear as explicit risk, not invisible assumption.

This is the point where a guideline becomes an executable boundary.

The decisive test is replay. If a historical execution requires actor_id, proposal_id, validated_at, confidence, AuthContext, or any cognitive data to be reproduced, the boundary has already been violated.


The property this preserves

With the formal boundary, the system preserves a property that is hard to add later:

the historical execution record is causal, not identity-based.

Two years from now, when someone audits an execution and asks what the system computed, the answer will come from a snapshot that contains only what was causally relevant.

The identity of who requested it will be in the audit ledger — structured, linked to the same moment by strong hash or reference, but separate from the causal record.

The separation also appears in the digests. The causal digest must include only causal inputs: command, versioned state, contracts, rules, technical parameters, and causal timestamps. Cognitive proposal, confidence score, actor, capability, confirmation evidence, and justification trail do not enter the causal identity of the snapshot. They can be linked by strong hash in the ledger or explainability layer, but they cannot alter the calculation.

Explanation is not causality either. An explainability layer can explain why a proposal was accepted, rejected, or executed, but it cannot retroactively become part of the calculation, except when a field is promoted to an explicit causal input before execution.

This separation allows the engine to evolve — new formats, new contracts, new rules — without requiring historical snapshot reads to know who the actors were at execution time.

And it allows something even more important: cognitive agents can participate in the system without degrading engine guarantees.

No process needs to know more than its responsibility requires.

That is the real insight of the pattern. It is not about placing AI behind validation. It is about preventing the cognitive model from becoming part of the engine's vocabulary.


When this pattern is necessary

The signal is not system complexity. It is the presence of two simultaneous requirements.

Requirement 1: the system integrates some kind of cognitive or semi-autonomous layer that proposes actions.

Requirement 2: the system has an engine that produces results that must be auditable, reproducible, or both.

If both requirements are present, the boundary between suggestion and execution cannot be implicit.

Every day the system operates without it is a day when contamination can happen — and probably happens slowly, in fields that look harmless at the time.

The truly expensive time to implement this separation is after the system is already in production: multiple services using a unified type, historical snapshots mixing causality and identity, tests that do not distinguish the two worlds.

Before that, it looks like only the decision to define separate artifacts. Afterward, it becomes historical migration, snapshot correction, and loss of confidence in replay.