Privacy: provider-streamed RawInput overrides on tool_progress events
are unredacted model output; for exposure-restricted tools they are now
discarded instead of replacing the projected form (the override was
reintroducing exactly the values the projector removed). Proven with a
progress event carrying a secret in the raw override.
Schema validation: proposal acceptance now validates through the
planner's exported canonical rules - FindCapability's exact-name
matching (the capture previously matched case-insensitively while
planning matched exactly) and ValidateParams for declared, required,
typed, enum, pattern, and malformed-schema cases - so a proposal that
validates is exactly a proposal the planner will accept. The
sensitive-parameter rejection remains a proposal-specific ratchet on
top.
Fail-closed ratchets: params and evidence identity are deep-cloned on
capture and again on outcome, so caller-side mutation after validation
can never alter the actionable proposal; fingerprint serialization
failures return errors rather than a shared sentinel value; an
investigation run refuses to start without finding and investigation
identity (before any provider call or session exists); and any run
error nils the proposal while preserving simultaneous proposal errors
via errors.Join - a non-nil proposal exists only from a completely
successful run.
Commit A of the coordinated proposal slice (pulse-internal; the
aicontracts interface replacement and enterprise migration follow as
one window because pulse-enterprise builds against this tree via a
replace directive).
patrol_propose_action is a side-effect-free, mutation-none capture
whose schema carries only resource_id, capability_name, params, and
reason. Registry policy rejects it outside the Patrol investigation
profile before the handler runs, and the same check keeps it out of
every other profile's projected manifest. Correlation identity
(proposal, finding, investigation, evidence) is injected from trusted
orchestration context through the request-local ProposalCapture sink,
which executor clones share so one run has exactly one capture.
Tool calls now carry an explicit invocation envelope (tool-use ID,
name, arguments) through ExecuteInvocation; the ID rides the context
because per-turn tool calls execute concurrently. The sink keys on call
identity plus payload fingerprint: idempotent replay re-succeeds, the
same ID with a different payload latches a terminal integrity error,
and a second distinct valid proposal latches terminal ambiguity - both
terminal states invalidate the captured proposal, since concurrency
makes first-wins nondeterministic. Proposals count only after catalog
validation: advertised capability, declared/required/enum parameters,
and sensitive parameters rejected before success with no value echo in
any output.
ExecuteInvestigationStream returns proposal cardinality as a structured
result with typed errors for ambiguity, integrity violations, and the
failed-attempts-only case (never collapsed into the valid zero-proposal
conclusion); ListInvestigationTools projects through the identical
profile path. Proposal parameter values exist only transiently for
provider continuation and validation: the canonical exposure projector
redacts them from the durable transcript and every
tool_start/progress/end stream event, proven end-to-end with a scripted
provider run that also verifies the provider continuation keeps raw
values.
Essential proof included: two concurrent valid proposal calls produce
ErrProposalAmbiguous and a nil proposal regardless of execution order.
Four classifier ratchets from review of 67c2534c0:
The classification vocabulary is closed: descriptor validation rejects
any class outside the known workflow kinds and mutation targets (an
empty Mutation no longer registers), and InvocationPolicy.Allows
independently denies unknown mutation targets outright, so a class
that somehow bypassed validation still cannot execute. Descriptor
lookups and registration store deep copies, so callers can never
mutate the canonical table through shared case maps or static class
pointers. Registration rejects descriptor overrides for canonical tool
names; overrides exist only for genuinely non-canonical extension and
test names.
Projected governance now derives its action mode from mutation
targets, not workflow kinds, and recomputes it even when no enum value
was filtered; a projection whose remaining invocations mutate nothing
downgrades to scope-only approval metadata (registered scope-only
summaries are preserved). Docker check_updates reclassifies from
{write,none} to {read,none}: it queues a read-only scan, and the write
kind was driving the FSM into verification and making the read-only
Docker projection report mixed. Discovery consequently projects as
mode=read in governance manifests, which is the honest mutation-derived
mode. The pulse_file_edit governance summary no longer claims to read
files. The contract prose also names pulse_read's execution-intent
classifier as mandatory second-stage enforcement for exec, not merely
defense in depth: the static read/none descriptor cannot prove
arbitrary command text safe.
Proofs: open-vocabulary rejection, unknown-target policy denial,
descriptor copy isolation, canonical-override rejection, and the
read-only Docker read/scope-only projection.
Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.
Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.
pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.
Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.
Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.