File size: 9,443 Bytes
f51c224
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
<?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 );
    }
}