Architecture decision: Observer first
Status: accepted for Iteration 0; automatic delivery amended for 0.3.1 and network evidence for 0.4.0 Date: 2026-08-19
Decision
ConfigOps is a native WordPress plugin whose supported floor is PHP 8.2. Its site boundary is request-local automatic observations plus optional named Change Sessions, Options API mutations, semantic nested diffs, provenance, and compensating restore. Version 0.7 can capture complete safe adapter-backed site option states as private declarative Packs and reproduce them through a previewed, conflict-checked Pack session. On a network-active Multisite installation, the 0.4 boundary also observes Network Options API mutations into a separate Network Admin ledger and can undo complete additions and updates one mutation at a time.
JavaScript is the interaction layer and may later observe labels, field names, tabs, and client-side requests, but only the PHP observer can assert that WordPress actually persisted a mutation. The wp-admin interface uses code-split React islands over a capability-gated REST boundary. Site-scoped WordPress Abilities and machine-readable JSON WP-CLI commands reuse the same PHP application services for bounded automation. Restore planning is read-only; a separate configops_apply capability plus an exact danger acknowledgement can authorize one mutation apply through the same guarded restore service. There is no Node service, monolithic SPA, cloud account, generic settings writer, or remote control plane in the evidence layer.
Why PHP is the right primary language
update_option() and its hooks execute inside PHP. Capturing old and new typed values, the current actor, request metadata, and the responsible call path is both more precise and cheaper in that same process. Reconstructing this from browser requests or database polling would lose internal writes, invent correlations, and complicate deployment.
PHP 8.2 is the oldest branch in the 0.4.0 runtime contract. The full parser, unit, hostile-input, and integration path is exercised from PHP 8.2 through 8.5, and an automated lifecycle gate forces the minimum to be reviewed when its upstream security support ends. This keeps the compatibility claim explicit instead of letting an end-of-life runtime remain supported by inertia.
Boundaries
| Concern | Observer authority | Later extension |
|---|---|---|
| Persisted mutation | Site and Network Options API hooks; value-free signal for unmanaged site writes | Adapter-owned custom tables and APIs |
| Human intent | Request-local automatic observation, optional session name, request context, and value-free admin-field correlation | Deeper fetch/REST correlation and reviewed adapter suggestions |
| Value semantics | Type-preserving codec, JSON Pointer diff, versioned field schemas, and bounded local media/content references | Cross-site semantic resolution and release transforms |
| Noise | Conservative built-in rules plus versioned WP Mail SMTP, Yoast, and WooCommerce contracts | Registry fixtures and adapter normalization |
| Secrets | Redact before persistence; preserve during field-level undo | Secret references and target-local resolution |
| Rollback | Conflict-checked full or adapter-backed field undo for site settings; conflict-checked full-value undo for ordinary network settings additions and updates | Network delete reconstruction plus authority/lifecycle commands and whole-capture undo |
| Pack transport | Private strict JSON desired state; no server-side Pack library | Signed/public distribution, variables, stacking, and drift evaluation |
| Storage | Dedicated session, mutation, write-signal, and restore-run tables; Pack applications add origin metadata to ordinary sessions | Deployment runs, snapshots, and drift tables when used |
| Local UI transport | Explicit REST resources and commands | Keep domain services independent from transport |
| Fleet read model | Not present | GraphQL over asynchronously materialized fleet state |
Domain invariants
- A
ConfigMutationis an observation, never an approved release change. - Unknown stays unknown; heuristics never impersonate adapter certainty.
- Secret plaintext never enters mutation persistence or exported diagnostics.
- Observation failure cannot fail the host settings request.
- Restore refuses a target whose current value no longer matches the observed result.
- List order is meaningful; associative key order is not.
- ConfigOps does not claim transactional rollback for effects it did not observe.
- An unmanaged write signal is not a
ConfigMutation: it proves only bounded write intent and never fabricates values, semantic paths, or rollback support. - Incomplete evidence is a durable product state: it disables whole-change undo and cannot be presented as a clean observation.
- Every undo attempt creates a value-free audit record before its first configuration write.
- A local reference stores bounded identity evidence, never media contents or post bodies; undo refuses a referenced item that no longer exists.
- A Pack is a desired state, never a source database snapshot or an executable program.
Hardening decisions
- The automatic boundary is request-local. An authorized administrative request creates no row until its first Options API mutation. It then owns an independent automatic session, so concurrent saves cannot replace or absorb one another. Technical-only automatic observations are discarded from operator history.
- Evidence storage is globally addressable and scope-isolated. The four evidence tables use the WordPress installation's base prefix. Every row carries an immutable
network_idandblog_id, and every repository query includes both values. Site evidence uses its real blog ID; network-owned evidence reserves blog ID0. This permits sites and their network to share one scalable table set without exposing or modifying each other's evidence. - The runtime site boundary remains request-pinned. ConfigOps captures the WordPress network and site identity at boot. After
switch_to_blog(), it resolves the switched blog's actualWP_Site::site_idownership instead of trusting WordPress's request-global current-network value, which does not reliably follow Multi-Network switches. Observers, commands, and lifecycle work fail closed when either identity differs. A pinned capture receives one durablecross_site_write_ignoredintegrity warning, no cross-site value is persisted, and temporary recovery writes restore the caller's original blog-switch stack position. - The network boundary is independently pinned. Network observation is registered only when ConfigOps is network-active, requires
manage_network_options, accepts only the owning network ID, and stores its state in network options. A Network Options write targeting another network remains a normal host write but is excluded from evidence; an open owning-network capture receives one durablecross_network_write_ignoredwarning. The Network Admin ledger has separate scoped REST routes. Automatic observations and named Network Change Sessions share one network-owned evidence scope, while the active named-session pointer remains isolated from every site's options. Conflict-checked mutation undo is available only for complete ordinary-settings additions and updates; whole-capture undo, redacted values, authority and plugin-lifecycle state, derived counters, and deletions remain unavailable. - Legacy storage migration is additive and idempotent. Rows in the former main-site tables are assigned to their original site in place. Existing per-site subsite tables are copied into shared storage with collision-safe ID remapping; mutation, write-signal, restore-audit, active-capture, and integrity-fallback references follow the new session IDs. Legacy tables are retained as a rollback source rather than deleted during upgrade.
- The plugin lifecycle follows both scopes. Network activation provisions existing sites in bounded batches, and a network-active installation provisions newly initialized sites after WordPress creates their roles and options. Every internal switch verifies that the selected site still belongs to the expected network; inactive sibling networks remain untouched. Network deactivation interrupts site- and network-owned evidence and removes retention schedules. Because WordPress blog IDs are installation-global, site deletion removes that blog ID's shared rows across current and stale former network identities plus its retained legacy tables. Uninstall additionally removes ConfigOps network options before dropping shared storage.
- Named-session ownership is atomic. The active-session option is acquired with
add_option(), so two concurrent named-session starts cannot silently replace one another. Release is an exact-value compare-and-delete in both site and network option storage: an older stop, interrupt, recovery, or failed activation cannot erase a newer owner's pointer. Stale pointers self-heal, and an activation that loses ownership discards only its own open row. - Named-session completion is verified. Stop-time mutation and unmanaged-write summaries must be readable before a session can become completed. Storage failure leaves the Change Session active for a safe retry; deactivation closes it as interrupted and permanently incomplete.
- Named-session finalization is explicit. Stop first moves the session through an atomic
stoppingstate. Evidence that finishes after that boundary marks the observation incomplete; an abandoned stop self-recovers to interrupted after five minutes so it cannot strand the observer or masquerade as complete. - Absence is not memoized. Positive active-session lookups are cached, but another integration may start or stop a named Change Session later in the same request.
- One causal write chain becomes one decision. Consecutive Options API writes to the same option, in the same request, by the same recognized core/plugin/theme owner are persisted as one baseline-to-final mutation. A chain that returns completely to its baseline is removed. An option change, request change, or provenance-owner change closes the aggregate so distinct causes and restore order remain explicit.
- Review diffs are semantic. Associative key order is ignored. A
null↔ empty-string coercion and an exact canonical integer ↔ integer-string coercion at the same existing path are not persisted as review noise. Formatted numeric strings such as"01", floats, booleans, list order, typed array keys, every other scalar-type change, option/key existence, and autoload mode remain significant. Restore conflict checks stay type-preserving. - Media references stay local and bounded. Site icon, site-logo, theme custom-logo, and explicit Yoast image-ID paths retain attachment identity alongside the raw local ID. Review resolves a current thumbnail on demand. Observation does not hash or copy the file, and undo never creates or deletes attachments.
- Content references stay local and bounded. Pinned Yoast publisher-policy, analysis-ignore, and LLMs.txt page paths retain only ID, title, post type, and status. Post bodies, excerpts, URLs, authors, and user records are not added. Undo refuses deleted or trashed content instead of restoring a broken local ID.
- User references disclose display identity only. Yoast’s represented-person selector retains user ID and display name so review does not show a bare ID. Email, login, roles, capabilities, and user metadata are never added; undo refuses a deleted account.
- Restore is serialized and compensating. Token-owned, expiring locks prevent overlapping restore requests in both site and network option scopes. Retention uses the same scope-owned mutex, so it cannot delete evidence while restore is reading a plan or its audit history. Site session restore preflights the entire plan, rechecks each value immediately before writing, then restores distinct options in reverse last-mutation order. Network restore handles one complete addition or update. Both verify the applied state and reapply the original current value when a write fails or is rewritten synchronously.
- Generic array undo is opt-in and three-way checked. For an unclaimed associative site option, the experiment accepts a patch only when every non-root change agrees with both encoded snapshots. Current adapter ownership is resolved again from the live registry, and every current parent must still be a string-keyed associative map before the patch engine runs. It then checks only those target paths against current state, preserves unrelated sibling changes, and compensates the complete current array on write failure. Integer-keyed parent arrays, list-index edits, numeric-string/list aliases, secrets, truncated or overlapping paths, adapter-owned options, and malformed evidence fail closed.
- Restore is auditable before it is mutable. A dedicated append-first run records actor, target scope, outcome, restored option count, and bounded failure code. It deliberately contains no option name, value, SQL, stack trace, or raw error message. Successful, refused, compensated, and compensation-failed attempts remain distinct.
- Work is budgeted. Value nodes, persisted payload size, diff operations, and backtraces have explicit upper bounds and disclose truncation or unsupported values.
- First paint cannot inherit history size. The server bootstrap excludes mutation diffs. The ledger fetches cursor pages only near the viewport, with both row and encoded-response budgets.
- Large sessions are not loaded whole. Admin review is paged and restore planning selects only required columns in bounded batches. It retains only the first and last state per option and refuses plans above 1,000 options or 64 MiB.
- Retention is bounded, scoped, and resumable. Daily cleanup removes at most 1,000 observations per site or network scope after 30 days, never selects active or unfinished sessions, marks each batch as deleting before child evidence is touched, and can safely resume after an interrupted cleanup without exposing a half-deleted review.
- Hot reads have matching indexes. Session review, stop-time recounts, and keyset restore iteration share a
(session_id, id)index instead of degrading into table scans as history grows. - Internal operations are invisible. Schema, lock, capability, and flash-notice options never appear in observations.
- Error reporting is also isolated. Even a third-party listener that throws during
configops_capture_errorcannot escape into the settings request being observed. If a ConfigOps table fails while WordPress still saves the host setting, a bounded, value-free emergency marker inwp_optionsmakes the session incomplete after storage recovers; unresolved markers produce a persistent administrator warning. - Schema upgrades fail safe. Upgrades are serialized, required tables and columns are verified before the version advances, and a failed normal boot disables ConfigOps with an administrator notice without taking WordPress down.
- Opaque credentials fail closed. Adapter and heuristic detection covers nested PHP values plus recognizable JSON, malformed JSON, XML-like documents, DSNs, authorization headers, and private keys before persistence. Structured strings deeper than the inspection budget are redacted rather than partially trusted.
- Adapters are capability-scoped. Observation ownership, field meaning, secret detection, rollback eligibility, and complete-option Pack Apply form separate levels. The destination must resolve the exact Pack adapter schema; partial, secret-bearing, unsupported, or unclaimed options never inherit Apply from observation support.
- Pack plans are short-lived and state-bound. Strict schema validation precedes destination analysis. An applicable preview stores only a user/site/Pack binding plus HMAC fingerprints for compatible baselines, expires after ten minutes, and is consumed once. Apply rebuilds the plan under the site mutex, compares every fingerprint, checks again before each write, verifies each desired state, and compensates prior writes in reverse order on failure.
- Meaning and provenance are pinned at observation time. Adapter-owned mutations retain adapter ID, schema version, and installed component version. An adapterless plugin mutation retains the captured source owner and its version when WordPress can resolve the owning main file. Source basis is persisted as either a causal caller or an exact request-local Settings API registration; a registration may replace a Core/unknown caller, never another plugin, must-use plugin, or theme already present in the write stack. Historical fields are enriched only when the matching schema is still available; generic leaf labels improve readability but never acquire adapter semantics after the fact.
- Derived state stays out of rollback. Cache, migration, tracking, version, and other adapter-declared runtime values remain visible under Technical. When they share an option with real settings, undo patches only the adapter-backed settings instead of reconstructing the whole option.
- Protected options are patched, never reconstructed. When a supported option also contains a secret, ConfigOps checks and reverses only adapter-backed non-secret paths against the current value. Credentials and plugin housekeeping remain byte-for-byte under the owning plugin’s control.
- Direct writes fail visibly, not magically. During a named session, or after an automatic request has established a configuration mutation, the SQL Sentry recognizes common write statements, ignores ConfigOps-owned tables and Options API duplicates, and stores only operation, table, count, provenance, and safe request metadata. Raw SQL and values never enter persistence. Fifty unique signals per request form a hard ceiling; repeated signals collapse by source.
- Uncorrelated core cron stays out of an admin task. Anonymous
/wp-cron.phpwrites are not attributed to an explicit operator Change Session. Synchronous plugin side effects in the user’s Save request remain visible; future async correlation requires an adapter-owned job token instead of timing guesses. - Unknown effects limit rollback. Any unmanaged database write disables full-session restore in the review contract. Individually supported Options API mutations remain conflict-checkable and restorable.
- Intent is evidence, never authority. A small admin observer records only the names, visible labels, sections, and submit action of fields the operator touches. A short-lived same-site cookie can bind that metadata either to a named session or to the next lazily created automatic observation, without sending a configuration value or adding a remote service. PHP accepts only bounded, current evidence whose option and JSON Pointer match the persisted diff. The result can explain an unknown field and summarize likely intent, but it cannot change classification, adapter compatibility, redaction, or restore eligibility.
Admin direction
The interface is a forensic change ledger: dense enough for professional review, calm enough to scan under incident pressure. Automatic evidence feedback provides the immediate site entry point; Recorded changes and named Change Sessions provide the deeper site review surface. Network Admin reuses the same evidence grammar behind a permanent scope band and omits controls it cannot safely honor. Unshipped product areas do not appear as inactive tiles. The design avoids equal-card dashboards and decorative infrastructure diagrams.
Three directions were considered:
- a WordPress-native settings table, rejected because it hides request causality;
- a pull-request imitation, useful for diffs but too code-host-specific as the whole product identity;
- a forensic ledger with request chapters and nested evidence, selected because provenance and uncertainty are the actual product material.
The visual direction remains server-shell plus forensic instruments: a compact product bar and evidence shell render before Recorded changes, Change Sessions, and Review become interactive. This preserves the ledger rather than replacing it with framework-shaped cards. The company brand is expressed through a scoped token mapping and the supplied SVG wordmark; Paper carries the ledger, Ink carries evidence headers, and Brand Blue marks the command or selection without a webfont or visual runtime. Explanations stay attached to specialist terms and restore actions through accessible hover/focus help instead of lengthening every row.
Trust harness
The deliberately hostile fixture plugin now exercises simple and nested options, typed WordPress IDs, secret redaction, transients, synchronous side effects, a versioned schema migration, AJAX metadata, direct SQL writes, and a neighboring plugin slug that shares the configops prefix. Integration contracts observe real attachment and content identities, render current availability, restore existing references, and refuse deleted targets before writing. Browser contracts install public WP Mail SMTP, Yoast, and WooCommerce releases, operate their real settings screens inside bounded named Change Sessions, review the resulting evidence, undo safe fields, and verify the result back in each plugin. WooCommerce’s first-run share key is redacted; empty country-list initialization, remote inbox polling, admin scheduler state, and proven job metadata remain technical instead of masking another plugin’s setting. The automatic WordPress Core flow separately verifies immediate evidence feedback after a normal settings save. A network-active Chromium contract saves and reviews real Network Settings, undoes a Network Title update, verifies the prior value in WordPress, and checks desktop and mobile widths. Version-line contracts additionally cover provider routing, less-obvious credential paths, dynamic social images, LLMs.txt page references, missing-reference refusal, and WooCommerce store settings. A live WordPress.org check refuses an exposed plugin version line that has no real-release contract.
Every tracked PHP file under src/ is also part of a reproducible Xdebug line-coverage run against an isolated WordPress and MariaDB installation. Unit, hostile-input, integration, and exact-adapter fragments are merged into LCOV, Clover, and JSON evidence. Unvisited and dead-code lines remain in the denominator. CI fails below 70% globally or 75% across the trust-boundary namespaces rather than allowing presentation coverage or never-loaded production files to hide risk. The complete method and its limits are recorded in testing.md.
Multisite boundary
ConfigOps keeps Network Admin evidence, named Network Change Sessions, narrow mutation-level undo, and opt-in structural undo for unclaimed site arrays as separate contracts. Complete ordinary Network Options additions and updates have network-scoped conflict, lock, audit, verification, and compensation contracts; the generic array experiment remains site-local. Deletes remain review-only because WordPress reports them after the previous value is unavailable. Super-admin authority, network-wide plugin lifecycle, and derived counters need dedicated WordPress commands rather than raw option replacement. Whole-capture undo, cross-site aggregation, bulk operations, and fleet control require separate safety and product contracts.