RcmEmailAutomation / sa-orchestration /includes /class-sa-actor-registry.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
8.82 kB
<?php
defined( 'ABSPATH' ) || exit;
/**
* Actor Registry — WP-User-backed identity layer (Board #22).
*
* Actors are persistent identity-bound roles — NOT disposable NPCs. Each
* actor is a real WordPress user with the 'sa_actor' role and structured
* metadata recorded in user_meta. The wp_users row + meta together IS the
* actor's persistent identity for this Board.
*
* Doctrine constraints (locked, non-negotiable):
* Actors may: speak, draft, advise, observe, coordinate
* Actors may NOT: progress Boards, accept/close tasks, mutate Asterion,
* silently act
*
* Sara grants voice. Asterion grants permission. Human grants progress.
*
* Implementation notes:
* - 'sa_actor' role gets only 'read' capability — nothing else. Actors
* are not real operators; they exist to be attributed.
* - Asterion ledger MD writes for actor records are out of this Board's
* scope. The dormant SA_Orch_Asterion writer stays dormant. Future
* Board can add literal canonical writes when the write surface
* activates.
* - Archive (sa_actor_is_active='0'), never delete. Actors are retired,
* not destroyed. Continuity preserved.
*/
class SA_Orch_Actor_Registry {
const ROLE = 'sa_actor';
// user_meta keys — namespaced 'sa_actor_*' to avoid collision with other plugins
const META_TYPE = 'sa_actor_type';
const META_PERSONA = 'sa_actor_persona';
const META_SCOPE = 'sa_actor_scope';
const META_OWNER_USER_ID = 'sa_actor_owner_user_id';
const META_IS_ACTIVE = 'sa_actor_is_active';
const META_SUBSCRIPTION_TIER = 'sa_actor_subscription_tier';
/** Idempotent role registration. Hooked on init. */
public static function register_role() {
if ( ! get_role( self::ROLE ) ) {
add_role( self::ROLE, __( 'SA Actor', 'sa-orchestration' ), [
'read' => true,
] );
}
}
/**
* List actors. Returns an array of WP_User objects.
* By default excludes archived actors; pass include_archived=true to see all.
*/
public static function list_actors( bool $include_archived = false ): array {
$args = [
'role' => self::ROLE,
'orderby' => 'display_name',
'order' => 'ASC',
'number' => 200,
];
if ( ! $include_archived ) {
$args['meta_query'] = [
'relation' => 'OR',
[ 'key' => self::META_IS_ACTIVE, 'value' => '1', 'compare' => '=' ],
[ 'key' => self::META_IS_ACTIVE, 'compare' => 'NOT EXISTS' ], // legacy/unset → treat as active
];
}
$users = get_users( $args );
return is_array( $users ) ? $users : [];
}
/**
* Hydrate an actor's full record. Returns null if user does not exist
* or does not have the sa_actor role (defensive — never expose
* non-actor users through this surface).
*/
public static function get_actor( int $user_id ): ?array {
$user = get_userdata( $user_id );
if ( ! $user || ! in_array( self::ROLE, (array) $user->roles, true ) ) {
return null;
}
$owner_id = (int) get_user_meta( $user_id, self::META_OWNER_USER_ID, true );
$owner_user = $owner_id ? get_userdata( $owner_id ) : null;
$is_active_v = get_user_meta( $user_id, self::META_IS_ACTIVE, true );
return [
'user_id' => (int) $user->ID,
'name' => (string) $user->display_name,
'login' => (string) $user->user_login,
'email' => (string) $user->user_email,
'type' => (string) get_user_meta( $user_id, self::META_TYPE, true ),
'persona' => (string) get_user_meta( $user_id, self::META_PERSONA, true ),
'scope' => (string) get_user_meta( $user_id, self::META_SCOPE, true ),
'owner_user_id' => $owner_id,
'owner_login' => $owner_user ? (string) $owner_user->user_login : '',
'is_active' => ( $is_active_v === '' || $is_active_v === '1' ),
'subscription_tier' => (string) get_user_meta( $user_id, self::META_SUBSCRIPTION_TIER, true ),
'created_at' => (string) $user->user_registered,
];
}
/**
* Create an actor. Required: name, type, persona (creation discipline —
* no nameless or roleless or purposeless actors). Owner defaults to
* current user. Email is synthetic if not provided (actors are not
* meant to log in).
*
* Returns the new user_id (int) or WP_Error on validation/creation failure.
*/
public static function create_actor( array $data ) {
$name = isset( $data['name'] ) ? trim( (string) $data['name'] ) : '';
$type = isset( $data['type'] ) ? trim( (string) $data['type'] ) : '';
$persona = isset( $data['persona'] ) ? trim( (string) $data['persona'] ) : '';
$scope = isset( $data['scope'] ) ? trim( (string) $data['scope'] ) : '';
$email = isset( $data['email'] ) ? trim( (string) $data['email'] ) : '';
$owner = isset( $data['owner_user_id'] ) ? (int) $data['owner_user_id'] : (int) get_current_user_id();
if ( $name === '' ) return new WP_Error( 'actor_name_required', 'Actor name is required (intent — no random assistants).' );
if ( $type === '' ) return new WP_Error( 'actor_type_required', 'Actor type is required (defined role).' );
if ( $persona === '' ) return new WP_Error( 'actor_persona_required', 'Actor persona is required (scoped purpose).' );
// Synthesize a unique login from the name. WP requires unique user_login.
$login_base = sanitize_user( strtolower( str_replace( ' ', '-', $name ) ), true );
if ( $login_base === '' ) $login_base = 'sa-actor';
$login = $login_base;
$i = 1;
while ( username_exists( $login ) ) {
$login = $login_base . '-' . $i++;
if ( $i > 200 ) {
return new WP_Error( 'actor_login_exhausted', 'Could not generate unique login for this actor name.' );
}
}
// Synthesize an email if not provided. Actors live as WP users for
// identity but are not expected to log in or receive mail.
if ( $email === '' ) {
$email = $login . '@sleeperagents.local';
}
if ( email_exists( $email ) ) {
$email = $login . '+' . wp_generate_password( 6, false ) . '@sleeperagents.local';
}
// Random password — actors are not meant to log in. We never expose it.
$password = wp_generate_password( 32, true, true );
$user_id = wp_insert_user( [
'user_login' => $login,
'user_email' => $email,
'user_pass' => $password,
'display_name' => $name,
'first_name' => $name,
'role' => self::ROLE,
] );
if ( is_wp_error( $user_id ) ) {
return $user_id;
}
update_user_meta( $user_id, self::META_TYPE, $type );
update_user_meta( $user_id, self::META_PERSONA, $persona );
update_user_meta( $user_id, self::META_SCOPE, $scope );
update_user_meta( $user_id, self::META_OWNER_USER_ID, $owner );
update_user_meta( $user_id, self::META_IS_ACTIVE, '1' );
update_user_meta( $user_id, self::META_SUBSCRIPTION_TIER, '' );
/**
* Hook for downstream consumers (e.g. a future Asterion ledger writer
* that records canonical actor identity in markdown). Today nothing
* listens; the hook exists so the future write surface can attach
* without re-architecting this Board.
*/
do_action( 'sa_orch_actor_created', (int) $user_id, $data );
return (int) $user_id;
}
/** Archive an actor (sets is_active=0). Returns true on success. */
public static function archive_actor( int $user_id ): bool {
$actor = self::get_actor( $user_id );
if ( ! $actor ) return false;
update_user_meta( $user_id, self::META_IS_ACTIVE, '0' );
do_action( 'sa_orch_actor_archived', $user_id );
return true;
}
/** Unarchive an actor (sets is_active=1). */
public static function unarchive_actor( int $user_id ): bool {
$actor = self::get_actor( $user_id );
if ( ! $actor ) return false;
update_user_meta( $user_id, self::META_IS_ACTIVE, '1' );
do_action( 'sa_orch_actor_unarchived', $user_id );
return true;
}
}