1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
//! **The permission decision, on the A3 controller** (B0b-1, #1842).
//!
//! B0a made both surfaces build one [`InteractionDefinition`]. This module
//! makes the ACCEPT/DENY decision itself run through
//! [`newt_interaction::validate_response`], on both surfaces.
//!
//! # What moved, and what did not
//!
//! [`Question::parse`](crate::Question::parse) is **kept**, demoted to what
//! it always actually was: an input DECODER. It turns an operator's
//! keystroke into a candidate action, applying the alias and
//! ambiguity-denial rules that are a presentation concern (`a` vs `A` are
//! distinct keys; an alias never shadows a real key; two matches deny).
//! What it no longer does is AUTHORIZE. That is now
//! `validate_response`'s: the candidate action is expressed as a
//! [`Response`] against the published definition, and only an `Accepted`
//! authorizes.
//!
//! The defence this buys is not theoretical. The registry of executable
//! actions is supplied by the CALLER and is INDEPENDENT of the definition
//! (epic law 13), so an action the definition offers but the caller never
//! registered is refused, and an action registered for the terminal is
//! refused when it arrives from the web — even if the form displayed it.
//!
//! # No persistence here
//!
//! Deliberately: B0b-1 changes no schema. The consequence is worth stating
//! precisely, because it bounds what the web surface proves. The
//! DEFINITION binding is authoritative on both surfaces — a submission
//! naming an action the published form does not carry is refused
//! everywhere. The INSTANCE binding is locally minted at answer time on the
//! web, so it is self-consistent rather than a proof that the answer
//! reached the offer the gate actually published. Persisting the instance,
//! and with it that cross-process binding, is #1846.
use newt_interaction::binding::{Refusal, RegisteredAction, ResponderContext};
use newt_interaction::lifecycle::{publish, HostMint, Lifecycle, LifecycleError};
use newt_interaction::{
AssertionKind, Audience, ControlId, ControlValue, IdempotencyKey, InteractionDefinition,
InteractionInstance, Nonce, OptionId, ProtocolError, Provenance, ResponderPolicy,
ResponderProvenance, Response, Scope, Submission,
};
use crate::interaction_adapter::DECISION_CONTROL;
use crate::PermissionAction;
/// How long an offer stays answerable, in the same nanosecond ticks the
/// store counts.
///
/// **The TTL reconciliation** (#1842). Two independent wall-clock
/// constants used to exist with nothing relating them: the store's
/// `PERMISSION_REQUEST_TTL_NANOS` (5 min) and the gate's
/// `web_decision_timeout` (4 min). The gate timeout being the shorter one
/// is load bearing — the gate must give up while the offer is still
/// answerable, or a decision could land against a row the store has
/// already aged out — and it was untested. An offer now carries ONE
/// number, taken from the store's, and
/// `b0b::the_gate_timeout_is_shorter_than_the_store_ttl` asserts the
/// relationship instead of leaving it to coincidence.
#[must_use]
pub fn offer_ttl_ticks() -> i64 {
crate::ConversationStore::PERMISSION_REQUEST_TTL_NANOS
}
/// The host's tick, in the same units the store's claim clock uses.
#[must_use]
pub fn now_tick() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| i64::try_from(d.as_nanos()).unwrap_or(i64::MAX))
}
/// Mint a host offer of `definition`, and publish it.
///
/// The nonce is fresh and unguessable: it is a routing handle that travels
/// beside the address, and possession of it authorizes nothing.
///
/// # Errors
///
/// [`LifecycleError`] when the offer does not bind the definition
/// presented — which cannot happen for an instance minted here, and is
/// propagated rather than unwrapped because this sits on the permission
/// path.
pub fn mint_offer(
definition: &InteractionDefinition,
workspace_key: &str,
conversation_id: &str,
audiences: &[Audience],
minted_tick: i64,
) -> Result<(InteractionInstance, Lifecycle), LifecycleError> {
let nonce = Nonce::new(format!("{minted_tick}-{}", uuid::Uuid::new_v4().simple()))
.map_err(LifecycleError::Protocol)?;
let instance = InteractionInstance {
schema: newt_interaction::InstanceTag,
nonce,
definition: definition
.definition_id()
.map_err(LifecycleError::Protocol)?,
revision: definition.revision,
ttl_ticks: offer_ttl_ticks(),
scope: Scope {
workspace_key: workspace_key.to_string(),
conversation_id: conversation_id.to_string(),
},
responder_policy: ResponderPolicy {
audiences: audiences.to_vec(),
// The terminal operator's authority comes from holding the
// terminal, and the web decision channel presents no
// credential today — so requiring one here would fail every
// real answer. #1839 records the enrollment-aware policy this
// should become.
requires_assertion: false,
},
provenance: Provenance {
origin: "permission-gate".to_string(),
minted_tick,
},
};
let lifecycle = publish(&HostMint::assert_host_authority(), &instance, definition)?;
Ok((instance, lifecycle))
}
/// Express a decoded action as a response to `instance`.
fn response_for(
definition: &InteractionDefinition,
instance: &InteractionInstance,
action: PermissionAction,
audience: Audience,
) -> Result<Response, ProtocolError> {
Ok(Response {
schema: newt_interaction::ResponseTag,
definition: definition.definition_id()?,
instance: instance.instance_id()?,
revision: definition.revision,
values: vec![Submission {
control: ControlId::new(DECISION_CONTROL)?,
value: ControlValue::Choice {
option: OptionId::new(action.as_str())?,
},
}],
idempotency_key: IdempotencyKey::new(uuid::Uuid::new_v4().simple().to_string())?,
responder_provenance: ResponderProvenance {
kind: match audience {
Audience::Terminal => AssertionKind::TerminalOperator,
// Every other audience presents no credential today. Recorded
// as unauthenticated rather than hidden.
_ => AssertionKind::Unauthenticated,
},
subject: match audience {
Audience::Terminal => "operator:terminal".to_string(),
_ => "operator:web".to_string(),
},
audience,
// No assertion is presented on either surface today. `None` is
// the honest record; `requires_assertion: false` above is what
// makes it admissible, and both are one decision, stated twice
// rather than assumed once.
assertion: None,
},
})
}
/// Authorize one decoded action against the offer it answers.
///
/// `registered` is the CALLER's registry of actions it can actually
/// execute. It is deliberately NOT derived from the definition: deriving
/// it would make [`Refusal::UnknownAction`] unfireable — every offered
/// option would be registered by construction — and would hand a
/// definition the power to name an executable thing (law 13).
///
/// # Errors
///
/// A [`Refusal`] when any binding fails. The caller denies; there is no
/// partial acceptance and no second opinion to consult.
pub fn authorize_action(
definition: &InteractionDefinition,
instance: &InteractionInstance,
lifecycle: &Lifecycle,
workspace_key: &str,
registered: &[RegisteredAction],
action: PermissionAction,
audience: Audience,
) -> Result<PermissionAction, Refusal> {
let response =
response_for(definition, instance, action, audience.clone()).map_err(Refusal::Protocol)?;
let accepted = newt_interaction::validate_response(
definition,
instance,
lifecycle,
&response,
&ResponderContext {
// The CALLER's fence, never the instance's own. Reading it off
// the record being checked would make
// `Refusal::WorkspaceMismatch` unfireable by construction —
// a check that cannot fail is decoration.
workspace_key,
registered,
},
)?;
// Exactly one control, so exactly one action — and it is the one the
// definition offered under that option id, not the one the caller
// asked about.
let [resolved] = accepted.actions.as_slice() else {
return Err(Refusal::MissingRequiredControl {
control: DECISION_CONTROL.to_string(),
});
};
crate::interaction_adapter::action_for_option(resolved.option.as_str()).ok_or_else(|| {
Refusal::UnknownAction {
option: resolved.option.as_str().to_string(),
}
})
}
/// The actions the permission gate can actually execute, and from which
/// audience.
///
/// **Independent of any definition, deliberately.** Deriving this from the
/// form would make [`Refusal::UnknownAction`] unfireable and would let a
/// definition name an executable thing (law 13). It is one registry shared
/// by both surfaces rather than a copy per surface, so the two cannot
/// drift.
///
/// The durable grants are terminal-only here, mirroring policy without
/// being derived from it — so a web submission naming `allow_permanent` is
/// refused as ineligible even if the form it was rendered from offered it.
#[must_use]
pub fn permission_registry(audience: Audience) -> Vec<RegisteredAction> {
let terminal_only = matches!(audience, Audience::Terminal);
let mut registry = Vec::new();
for (action, handler, durable) in [
(PermissionAction::AllowOnce, "gate::allow_once", false),
(PermissionAction::AllowSession, "gate::allow_session", false),
(
PermissionAction::AllowPermanent,
"gate::allow_permanent",
true,
),
(PermissionAction::Deny, "gate::deny", false),
(PermissionAction::DenyAlways, "gate::deny_always", true),
(
PermissionAction::DenyPermanent,
"gate::deny_permanent",
true,
),
] {
if durable && !terminal_only {
continue;
}
let (Ok(option), Ok(handler)) = (
OptionId::new(action.as_str()),
newt_interaction::binding::HandlerId::new(handler),
) else {
continue;
};
registry.push(RegisteredAction {
option,
handler,
audiences: vec![audience.clone()],
});
}
registry
}
/// Authorize an action against a PERSISTED offer, returning the validated
/// [`Response`].
///
/// B0b-2 (#1846): the transport stores the instance, so the response is
/// validated against the offer that was actually published rather than one
/// minted at answer time — the cross-process bound B0b-1 could not carry.
/// The body is also what makes `answered_by` survive: it carries
/// `responder_provenance.audience`.
///
/// # Errors
///
/// A [`Refusal`] when any binding fails.
pub fn authorized_response(
definition: &InteractionDefinition,
instance: &InteractionInstance,
workspace_key: &str,
action: PermissionAction,
audience: Audience,
) -> Result<Response, Refusal> {
// Re-publishing a persisted offer is how its lifecycle is recovered,
// and it is not a formality: `publish` re-checks the digest and
// revision binding, so a stored instance that does not match its
// stored definition cannot be answered at all.
let lifecycle =
publish(&HostMint::assert_host_authority(), instance, definition).map_err(|e| match e {
LifecycleError::Protocol(p) => Refusal::Protocol(p),
_ => Refusal::NotPublished {
state: newt_interaction::LifecycleState::Draft,
},
})?;
let response =
response_for(definition, instance, action, audience.clone()).map_err(Refusal::Protocol)?;
newt_interaction::validate_response(
definition,
instance,
&lifecycle,
&response,
&ResponderContext {
// The CALLER's fence, compared against the one the OFFER
// carries. Passing the instance's own key would make
// `Refusal::WorkspaceMismatch` a tautology — the same trap
// B0b-1 fixed on the terminal path.
workspace_key,
registered: &permission_registry(audience),
},
)?;
Ok(response)
}