RcmEmailAutomation / sa-orchestration /includes /class-sa-acceptance.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
9.44 kB
<?php
defined( 'ABSPATH' ) || exit;
/**
* Append-only by design. Class exposes only write() and read methods.
* No update(), no delete().
*/
class SA_Orch_Acceptance {
const ALLOWED_EVENT_TYPES = [
'result_claimed',
'acceptance_disputed',
'acceptance_affirmed',
'acceptance_revoked',
];
const ALLOWED_ACTOR_ROLES = [
'agent',
'stakeholder',
'system',
'inferred',
];
public static function write( array $args ) {
global $wpdb;
$demand_surface = isset( $args['demand_surface'] ) ? trim( (string) $args['demand_surface'] ) : '';
$demand_ref = isset( $args['demand_ref'] ) ? trim( (string) $args['demand_ref'] ) : '';
$event_type = isset( $args['event_type'] ) ? trim( (string) $args['event_type'] ) : '';
$actor_role = isset( $args['actor_role'] ) ? trim( (string) $args['actor_role'] ) : '';
if ( $demand_surface === '' || $demand_ref === '' ) {
return new WP_Error( 'missing_demand', 'demand_surface and demand_ref are required' );
}
if ( ! in_array( $event_type, self::ALLOWED_EVENT_TYPES, true ) ) {
return new WP_Error( 'invalid_event_type', 'event_type must be one of: ' . implode( ', ', self::ALLOWED_EVENT_TYPES ) );
}
if ( ! in_array( $actor_role, self::ALLOWED_ACTOR_ROLES, true ) ) {
return new WP_Error( 'invalid_actor_role', 'actor_role must be one of: ' . implode( ', ', self::ALLOWED_ACTOR_ROLES ) );
}
if ( ! in_array( $demand_surface, SA_Orch_Bridge_Fluent::known_surfaces(), true ) ) {
return new WP_Error( 'unknown_surface', "demand_surface '$demand_surface' is not in known vocabulary" );
}
if ( ! SA_Orch_Bridge_Fluent::validate( $demand_surface, $demand_ref ) ) {
return new WP_Error( 'invalid_demand_ref', "demand_ref does not resolve: {$demand_surface}/{$demand_ref}" );
}
$prior_event_id = isset( $args['prior_event_id'] ) ? (int) $args['prior_event_id'] : null;
if ( $prior_event_id ) {
$exists = $wpdb->get_var( $wpdb->prepare(
"SELECT id FROM {$wpdb->prefix}" . SA_ORCH_DB_PREFIX . "acceptance_event WHERE id = %d",
$prior_event_id
) );
if ( ! $exists ) {
return new WP_Error( 'invalid_prior_event', "prior_event_id $prior_event_id not found" );
}
}
$actor_user_id = isset( $args['actor_user_id'] ) ? (int) $args['actor_user_id'] : get_current_user_id();
if ( $actor_user_id === 0 ) { $actor_user_id = null; }
$details = isset( $args['details'] ) ? (string) $args['details'] : null;
$truth_class = isset( $args['truth_class'] ) && $args['truth_class'] !== ''
? (string) $args['truth_class']
: 'canonical';
$observed_at = isset( $args['observed_at'] ) && $args['observed_at'] !== ''
? (string) $args['observed_at']
: current_time( 'mysql', true );
$recorded_at = current_time( 'mysql', true );
$hash = self::compute_hash( $demand_surface, $demand_ref, $event_type, $observed_at, $prior_event_id );
$frontmatter = [
'kind' => 'acceptance_event',
'hash' => $hash,
'demand_surface' => $demand_surface,
'demand_ref' => $demand_ref,
'event_type' => $event_type,
'prior_event_id' => $prior_event_id,
'actor_user_id' => $actor_user_id,
'actor_role' => $actor_role,
'truth_class' => $truth_class,
'observed_at' => $observed_at,
'recorded_at' => $recorded_at,
];
$body = "## Details\n\n" . ( $details !== null && $details !== '' ? $details : '(none)' ) . "\n";
$rel_path = SA_Orch_Asterion::write( 'acceptance_event', $hash, $frontmatter, $body );
if ( is_wp_error( $rel_path ) ) {
return $rel_path;
}
$table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event';
$ok = $wpdb->insert( $table, [
'hash' => $hash,
'demand_surface' => $demand_surface,
'demand_ref' => $demand_ref,
'event_type' => $event_type,
'prior_event_id' => $prior_event_id,
'actor_user_id' => $actor_user_id,
'actor_role' => $actor_role,
'details' => $details,
'truth_class' => $truth_class,
'asterion_path' => $rel_path,
'observed_at' => $observed_at,
'recorded_at' => $recorded_at,
] );
if ( $ok === false ) {
return new WP_Error( 'db_insert_failed', $wpdb->last_error, [ 'asterion_path' => $rel_path ] );
}
$event_id = (int) $wpdb->insert_id;
// v0.6.0: notify projection / side-effect listeners.
do_action( 'sa_orch_acceptance_event_written', [
'id' => $event_id,
'hash' => $hash,
'demand_surface' => $demand_surface,
'demand_ref' => $demand_ref,
'event_type' => $event_type,
'actor_role' => $actor_role,
'actor_user_id' => $actor_user_id,
'truth_class' => $truth_class,
'asterion_path' => $rel_path,
] );
return [
'id' => $event_id,
'hash' => $hash,
'asterion_path' => $rel_path,
];
}
/**
* Latest event per Demand. Useful for closure-rule queries.
* Returns object|null.
*/
public static function latest_for_demand( $demand_surface, $demand_ref ) {
global $wpdb;
$table = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'acceptance_event';
return $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM {$table}
WHERE demand_surface = %s AND demand_ref = %s
ORDER BY observed_at DESC, id DESC LIMIT 1",
$demand_surface,
$demand_ref
) );
}
/**
* Basic closure state.
* Returns: 'no-events' | 'open:<event_type>' | 'closed'
*
* 'closed' iff the latest event is acceptance_affirmed (regardless of who affirmed it).
* Existing behavior since v0.1; preserved.
*/
public static function basic_closure_state( $demand_surface, $demand_ref ) {
$latest = self::latest_for_demand( $demand_surface, $demand_ref );
if ( ! $latest ) return 'no-events';
if ( $latest->event_type !== 'acceptance_affirmed' ) return 'open:' . $latest->event_type;
return 'closed';
}
/**
* Role-aware closure state.
* Returns: 'no-events' | 'open:<event_type>' | 'role-mismatch:<actor_role>' | 'closed'
*
* 'closed' iff the latest event is acceptance_affirmed AND its actor_role is 'stakeholder'.
* Otherwise reports the specific reason closure is not authoritative.
*
* Per SIGNAL_FLOW.md closure rule: only stakeholder affirmation closes a Demand.
* Agent-self-affirmation, system-affirmation, etc. write successfully (append-only is preserved)
* but do not constitute closure under this stricter rule.
*/
public static function role_aware_closure_state( $demand_surface, $demand_ref ) {
$latest = self::latest_for_demand( $demand_surface, $demand_ref );
if ( ! $latest ) return 'no-events';
if ( $latest->event_type !== 'acceptance_affirmed' ) return 'open:' . $latest->event_type;
if ( $latest->actor_role !== 'stakeholder' ) return 'role-mismatch:' . $latest->actor_role;
return 'closed';
}
/**
* Closure authority validity.
*
* Returns:
* null — no acceptance_affirmed has been recorded yet (latest is not affirmed, or no events at all).
* Authority is not yet relevant; closure is not being claimed.
* true — latest acceptance_affirmed event has actor_role = 'stakeholder'. Closure is authoritative.
* false — latest acceptance_affirmed event has actor_role != 'stakeholder'. Closure is claimed
* but the actor lacked closure authority. Recording is preserved (append-only); the
* closure_state_role_aware field will read 'role-mismatch:<role>'.
*/
public static function closure_authority_valid( $demand_surface, $demand_ref ) {
$latest = self::latest_for_demand( $demand_surface, $demand_ref );
if ( ! $latest ) return null;
if ( $latest->event_type !== 'acceptance_affirmed' ) return null;
return $latest->actor_role === 'stakeholder';
}
private static function compute_hash( $demand_surface, $demand_ref, $event_type, $observed_at, $prior_event_id ) {
// wp_rand suffix prevents collision when same demand+event_type+observed_at recorded twice
$material = implode( '|', [
$demand_surface,
$demand_ref,
$event_type,
$observed_at,
(int) $prior_event_id,
wp_rand(),
] );
return substr( hash( 'sha256', $material ), 0, 32 );
}
}