Install
$ agentstack add skill-lonsdale201-wp-agent-skills-bd-sink ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
better-data: Adding a sink
For library maintainers integrating WRITE-side support for a new WordPress data store with better-data — comment meta, attachments, custom taxonomies, plugin-specific tables. The shape is set by PostSink and OptionSink; deviating breaks the HasWpSinks trait API and surprises consumers who've internalized the dual projection-vs-convenience model.
Misconception this skill corrects
> "I'll write the values directly to update_post_meta — slashing is the caller's problem."
Wrong direction. WordPress's write pipeline calls wp_unslash() on inbound data on the way to the DB. If you pass a raw value through update_post_meta($id, $key, 'a"b') without slashing first, WP unslashes a string with no slashes and stores 'ab' (the " survives, but escaped/quoted values get mangled). Convenience methods (insert, update, save) MUST wp_slash() before calling any WP write function. Verified in [src/Sink/PostSink.php:144](PostSink.php) ($args = \wp_slash($args);), [PostSink.php:183](PostSink.php) (\wp_update_post(\wp_slash($args), true)), [PostSink.php:191](PostSink.php) (\update_post_meta($postId, $key, \wp_slash($value))).
The mirror trap on the projection side: toArgs/toMeta MUST return raw values, NOT pre-slashed. Callers that take projections and pass them to their OWN WP write calls (wp_insert_post) would double-slash and corrupt every backslash on round-trip.
So: convenience slashes, projection does not. The two paths share SinkProjection::prepareValue ([src/Internal/SinkProjection.php:193](SinkProjection.php)) which handles type-shaping, encryption, and DataObject unwrapping but never slashes.
Other AI-prone misconceptions:
- "Asymmetric write/read is fine — encrypt on write, store as plain on read." Wrong. Every asymmetric encryption bug in Phase-8.7's OptionSink came from this pattern. If you encrypt, the matching source MUST decrypt.
- "Storing a
DataObjectinstance withupdate_post_meta($id, 'thing', $dto)— WP will serialize it." Technically true, but stores a class name in the DB; on class rename or removal you have unrecoverable garbage. Always project throughSinkProjection::prepareValuewhich recurses arrays and turns nestedDataObjects into plain arrays. - "I'll do my own slashing inside
prepareValue." Wrong layer — projection stays raw. Slashing is the boundary concern at the WP-call site.
When to use this skill
Trigger when ANY of the following is true:
- Creating a new file under
src/Sink/. - Adding
toArgs/toMeta/insert/update/savemethods on a sink class. - Adding a
saveAsXshortcut toHasWpSinks. - Reviewing a PR that calls
update_*_meta/wp_insert_*directly withoutwp_slash(). - Reviewing a PR that adds at-rest encryption to one sink without verifying the matching source decrypts.
Workflow
1. File location
Sinks live in src/Sink/. The pure projection logic (no WP calls) lives in src/Internal/SinkProjection.php — DON'T duplicate it. Your sink delegates type shaping there and adds the WP-specific calls.
2. The two-mode contract
Every sink ships TWO modes that share one projection:
Projection mode (caller drives the write):
public static function toArgs(
DataObject $dto,
?array $only = null,
bool $strict = false,
bool $skipNullDeletes = false,
): array;
/**
* @return array{write: array, delete: list}
*/
public static function toMeta(
DataObject $dto,
?array $only = null,
bool $strict = false,
bool $skipNullDeletes = false,
): array;
toArgs returns the array shape wp_insert_ / wp_update_ accepts. toMeta returns ['write' => [k => v, ...], 'delete' => [k, k, ...]] — split because the meta write loop is "set non-nulls, delete nulls".
Convenience mode (sink drives the write):
public static function insert(DataObject $dto, ?array $only = null, bool $strict = false): int;
public static function update(DataObject $dto, ?array $only = null, bool $strict = false): bool;
public static function save(DataObject $dto, ?array $only = null, bool $strict = false): int;
All three internally call toArgs + toMeta, then issue WP function calls with wp_slash() applied at the boundary. save is the smart router: positive DTO id → update, otherwise insert.
3. Slashing policy — the rule that breaks every refactor
// Inside convenience methods (insert / update / save):
$args = \wp_slash($args);
\wp_insert_post($args, true);
\update_post_meta($postId, $key, \wp_slash($value));
// Inside projection methods (toArgs / toMeta):
return $args; // RAW, no slashing
Reason ([PostSink.php:32-43](PostSink.php) docblock): "WP write pipeline calls wp_unslash() on inbound data. Without slashing, a value containing \" would round-trip to "." But the projection caller may build a payload that's already-slashed for a different reason; double-slashing corrupts equally. The split is the API contract.
4. Null = delete (the meta convention)
The sink's meta loop:
foreach ($meta['write'] as $key => $value) {
\update_post_meta($postId, $key, \wp_slash($value));
}
foreach ($meta['delete'] as $key) {
\delete_post_meta($postId, $key);
}
A DTO field that's null doesn't update the meta to NULL — it DELETES the meta entry. This pairs with the source's null-vs-empty distinction: a deleted-meta-key reads back as null (per the metadata_exists contract), which hydrates the DTO's parameter default.
The $skipNullDeletes flag lets a caller opt out: when the user is doing a partial update of a single field and doesn't want unrelated nulls to wipe other meta. Default behavior is "null deletes".
5. Honor #[Encrypted] symmetrically
The projection layer routes encrypted fields through EncryptionEngine::encrypt. Verified at [src/Sink/OptionSink.php:134](OptionSink.php):
$value = EncryptionEngine::encrypt($value);
If your sink touches rich types (Secret, plain string with #[Encrypted]), EVERY write path must encrypt. The matching source's read path must decrypt. Asymmetric is worse than absent — the user puts a credential in, the user gets a credential out, but in between the DB stores plaintext.
The unit-test contract for any sink that writes #[Encrypted] fields:
- Round-trip: hydrate → DTO → save (sink) → load (source) → DTO → unwrap → equals original plaintext.
- Tamper probe: load the DB row directly, flip a byte, source-load → expect
DecryptionFailedException. - Leak probe: dump the projected
toArgs/toMetaarrays — should contain ciphertext, NOT the plaintext.
6. Add the HasWpSinks shortcut
Mirror [src/Sink/HasWpSinks.php:36-115](HasWpSinks.php):
// Inside trait HasWpSinks:
public function saveAsComment(?array $only = null): int
{
/** @var DataObject $this */
return CommentSink::save($this, $only);
}
public function toCommentArgs(?array $only = null): array
{
/** @var DataObject $this */
return CommentSink::toArgs($this, $only);
}
DTO authors then write:
$dto = (new CommentDto(post_id: 5, content: 'hi'))->saveAsComment();
7. Identifier requirement for update
Updating without an ID is a programming error. Throw a typed exception ([src/Exception/MissingIdentifierException.php](MissingIdentifierException.php) is the existing one):
public static function update(DataObject $dto, ?array $only = null, bool $strict = false): bool
{
$id = self::resolveId($dto);
if ($id $stored]);
}
}
// RIGHT — symmetric
class FooSource {
public static function hydrate(string $option, string $dtoClass): DataObject {
$stored = \get_option($option);
if ($parameter has #[Encrypted]) {
$stored = EncryptionEngine::decrypt($stored);
}
// ...
}
}
// WRONG — null doesn't delete
foreach ($meta as $key => $value) {
\update_post_meta($postId, $key, $value); // null stored as empty / falsy
}
// RIGHT — null deletes
foreach ($meta['write'] as $key => $value) {
\update_post_meta($postId, $key, \wp_slash($value));
}
foreach ($meta['delete'] as $key) {
\delete_post_meta($postId, $key);
}
// WRONG — storing a DataObject instance directly
\update_post_meta($postId, 'cart', $cartDto);
// Stores serialized PHP with namespaced class names. On rename, you've lost the data.
// RIGHT — project first
$projected = SinkProjection::projectForStorage($cartDto); // recursively turns DTOs into arrays
\update_post_meta($postId, 'cart', \wp_slash($projected));
// WRONG — update without ID guard
public static function update(DataObject $dto): bool {
\wp_update_post(['ID' => $dto->id, ...], true); // 🔴 ID = 0 => create-with-ID-0 weirdness
}
// RIGHT
public static function update(DataObject $dto): bool {
$id = self::resolveId($dto);
if ($id <= 0) {
throw MissingIdentifierException::for($dto::class, 'ID');
}
// ...
}
Cross-references
- Run
bd-source-adapterwhen also reading from the same store — sinks and sources usually ship as a pair. - Run
bd-attributeif the new sink consumes a NEW attribute (e.g.#[CommentField]) — wire it everywhere relevant in one pass. - Run
bd-securitywhen the new sink touchesSecret,#[Encrypted], or any leak-prevention path. Asymmetric encryption is a security regression.
What this skill does NOT cover
- Bulk write performance (transactions, deferred meta writes). Most sinks don't need it; if you do, design it on top of the two-mode contract, don't replace it.
- Schema migration / DB table creation. Sinks assume the storage exists.
- Replacing
SinkProjection::prepareValue. New shaping behavior is an attribute (bd-attribute), not a parallel projector. - Atomic multi-table writes. WP doesn't expose proper transactions — the convenience methods are best-effort. Compensating writes are caller-side.
- REST output projection — that's the
Presenter, not a sink. Usebd-presenter.
References
- Reference sink (full pattern): [libraries/better-data/src/Sink/PostSink.php](PostSink.php) — class docblock at lines 12-50, slashing at 144 / 183 / 191,
MissingIdentifierExceptionusage in update. - Encryption-handling sink: [libraries/better-data/src/Sink/OptionSink.php:119-160](OptionSink.php) —
projectForStoragewithEncryptionEngine::encryptat line 134. - Pure projection: [libraries/better-data/src/Internal/SinkProjection.php:193](SinkProjection.php) —
prepareValue, recurses into arrays and DTOs. - Trait shortcuts: [libraries/better-data/src/Sink/HasWpSinks.php:31-115](HasWpSinks.php) —
saveAsPost,saveAsUser,saveAsTerm,saveAsOption,saveAsRow,toPostArgs, etc. - Other sinks: [libraries/better-data/src/Sink/UserSink.php](UserSink.php), [libraries/better-data/src/Sink/TermSink.php](TermSink.php), [libraries/better-data/src/Sink/RowSink.php](RowSink.php).
- Encryption engine: [libraries/better-data/src/Encryption/EncryptionEngine.php](EncryptionEngine.php) —
encrypt,decrypt, key handling.
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Lonsdale201
- Source: Lonsdale201/wp-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.