Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
c7b78fe
Record the AWS provider and the thread's silence in the journal track…
ericmann Sep 24, 2026
e494b49
Add ADR 0008: the Trac ticket replaces thread confirmation
ericmann Sep 24, 2026
1209b50
Spec the KMS keyring, Vault provider, and WP-CLI smoke test
ericmann Sep 24, 2026
3f9cdfb
Add the Foundry spec for this flight
ericmann Sep 24, 2026
69ab537
plan: derive build plan from SPEC
ericmann Sep 24, 2026
02500e6
chore: start implementation run
ericmann Sep 24, 2026
62ec7e7
P0-01: Add the keyring conformance suite and make Mock_Keyring pass it
ericmann Sep 24, 2026
79a27c6
progress: P0-01 done
ericmann Sep 24, 2026
90c4d53
P0-02: State the non-determinism requirement in the keyring interface…
ericmann Sep 24, 2026
0a1af7b
progress: P0-02 done
ericmann Sep 24, 2026
54972f8
P0-03: Push phase 0
ericmann Sep 24, 2026
336a2fe
progress: P0-03 done
ericmann Sep 24, 2026
9458df6
P1-01: Cache the unwrapped root key in WP_Secrets_Key_Manager for the…
ericmann Sep 24, 2026
0ab008d
progress: P1-01 done
ericmann Sep 24, 2026
f666293
P1-02: Document root-key caching: examples README, spec page, ADR 0009
ericmann Sep 24, 2026
2290349
progress: P1-02 done
ericmann Sep 24, 2026
e8ed503
P1-03: Push phase 1
ericmann Sep 24, 2026
9e29952
progress: P1-03 done
ericmann Sep 24, 2026
72afe57
P2-01: Generalise wp secret rotate with --from and re-wrap under the …
ericmann Sep 24, 2026
01577a0
progress: P2-01 done
ericmann Sep 24, 2026
565e4d2
P2-02: Push phase 2
ericmann Sep 24, 2026
298c235
progress: P2-02 done
ericmann Sep 24, 2026
3b8fba6
P3-01: Add the examples PHPUnit harness, Moto, and the AWS Secrets Ma…
ericmann Sep 24, 2026
ad9a198
progress: P3-01 done
ericmann Sep 24, 2026
95c54fb
P3-02: Add the examples CI job with a pinned Moto service container
ericmann Sep 24, 2026
50a9e86
progress: P3-02 done
ericmann Sep 24, 2026
747d8c8
P3-03: Push phase 3
ericmann Sep 24, 2026
4a875a4
progress: P3-03 done
ericmann Sep 24, 2026
f60fbd2
P4-01: Write the AWS KMS keyring example and run the keyring conforma…
ericmann Sep 24, 2026
b2972ca
progress: P4-01 done
ericmann Sep 24, 2026
534c455
P4-02: Prove the KMS keyring end to end: round trip, one Decrypt per …
ericmann Sep 24, 2026
a5e7945
progress: P4-02 done
ericmann Sep 24, 2026
01fed46
P4-03: Write the AWS KMS keyring README with the adoption walkthrough
ericmann Sep 24, 2026
48a082a
progress: P4-03 done
ericmann Sep 24, 2026
38f06eb
P4-04: Push phase 4
ericmann Sep 24, 2026
acdea10
progress: P4-04 done
ericmann Sep 24, 2026
33994e1
P5-01: Bring the spec pages in line with the code
ericmann Sep 24, 2026
5101524
progress: P5-01 done
ericmann Sep 24, 2026
3d72861
P5-02: Update the journal tracking pages, the READMEs, and the index
ericmann Sep 24, 2026
298e0bc
progress: P5-02 done
ericmann Sep 24, 2026
d9729c0
P5-03: Write the dev journal entry
ericmann Sep 24, 2026
20355dd
progress: P5-03 done
ericmann Sep 24, 2026
f4801c8
P5-04: Push phase 5, remove the Moto container, record the live-KMS c…
ericmann Sep 24, 2026
770baa7
progress: P5-04 done
ericmann Sep 24, 2026
4dd2440
chore: handoff for review
ericmann Sep 24, 2026
80a3c94
chore: round 0 implemented
ericmann Sep 24, 2026
af86980
chore: pipeline friction (review)
ericmann Sep 24, 2026
c763b8c
review: round 1
ericmann Sep 24, 2026
cbbded7
chore: start review-fix round 1
ericmann Sep 24, 2026
387d21d
R1-01: Restore the misconfigured-WP_SECRETS_KEY scenario in the three…
ericmann Sep 24, 2026
73cb6b5
progress: R1-01 done
ericmann Sep 24, 2026
936d773
R1-02: Correct the published docs: journal finding, worktree-specific…
ericmann Sep 24, 2026
0ceb68b
progress: R1-02 done
ericmann Sep 24, 2026
dab7098
chore: handoff for review
ericmann Sep 24, 2026
7ffc9b7
chore: round 1 implemented
ericmann Sep 24, 2026
1482e4a
review: round 2
ericmann Sep 24, 2026
47e73f4
chore: start review-fix round 2
ericmann Sep 24, 2026
19be872
R2-01: Correct the Secrets Manager README CI claim and remove Foundry…
ericmann Sep 24, 2026
e294735
progress: R2-01 done
ericmann Sep 24, 2026
1ce6550
chore: handoff for review
ericmann Sep 24, 2026
3c690b0
chore: round 2 implemented
ericmann Sep 24, 2026
e174011
review: round 3 approved
ericmann Sep 24, 2026
a117e26
chore: build summary
ericmann Sep 24, 2026
7276e2f
Remove the Foundry build bookkeeping
ericmann Sep 25, 2026
5e8be79
Date the journal tracking pages this branch changed
ericmann Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,66 @@ jobs:
run: make install WP_VERSION=latest DB_HOST=127.0.0.1

- run: make test-ms

# Outside `make ci` because it needs a service container (Moto, an AWS
# emulator) that the other jobs and the no-Docker local path do not provide.
# The examples under examples/ stay unlinted -- they are single files a host
# copies out, not part of this plugin's own coding-standard surface.
examples:
name: Examples (Moto)
needs: static
runs-on: ubuntu-latest
env:
WP_SECRETS_TEST_AWS_ENDPOINT: http://127.0.0.1:5000
services:
mysql:
image: mysql:8.0
env:
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
MYSQL_DATABASE: wordpress_test
ports:
- 3306:3306
options: >-
--health-cmd="mysqladmin ping"
--health-interval=10s
--health-timeout=5s
--health-retries=5
# Pinned by digest, resolved from motoserver/moto:latest on 2026-09-24.
moto:
image: motoserver/moto@sha256:91fd602a21f49cf9eb82fdf474015a3c131d40104c8297ea6a2ca920708ae32c
ports:
- 5000:5000
steps:
- name: Check out
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2
with:
php-version: '8.3'
extensions: sodium, mysqli
coverage: none
tools: composer

- name: Cache Composer packages
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/composer
key: composer-${{ runner.os }}-php8.3-${{ hashFiles('composer.lock') }}
restore-keys: composer-${{ runner.os }}-php8.3-

- name: Install dependencies and the WordPress test suite
run: make install WP_VERSION=latest DB_HOST=127.0.0.1

- name: Wait for Moto
run: |
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:5000/moto-api/ >/dev/null; then
exit 0
fi
sleep 1
done
echo "Moto never answered on http://127.0.0.1:5000/moto-api/" >&2
exit 1

- run: make test-examples
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,3 @@ site/.astro/
# Spacefast CLI link and state. Written wherever sf publish runs from; never commit it.
.spacefast/

# Private reviewer asks; drafted locally, never committed.
docs-review-asks.md
11 changes: 7 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,19 @@ those do not.

- `docs/` is the source of truth for the documentation site. `site/` only renders it: the Astro
project reads `../docs` directly, and nothing under `site/src/` is content.
- `docs/reference/` is generated by `bin/gen-reference.php` from source docblocks. Never edit
those files by hand. Change the docblock, run `make reference`, and commit the result; `make ci`
and a CI job fail when the committed copy is stale.
- `docs/reference/functions.md`, `classes.md`, `hooks.md`, and `wp-cli.md` are generated by
`bin/gen-reference.php` from source docblocks. Never edit those four by hand. Change the
docblock, run `make reference`, and commit the result; `make ci` and a CI job fail when the
committed copy is stale. The other files in `docs/reference/` are written by hand.
- Spec pages under `docs/spec/` use exactly three sections, in this order: **As proposed**,
**As built**, **Why**. "Why" stays empty when the code matches the proposal.
- The make/core proposal is linked, never restated. One or two sentences plus the link.
- Design decisions are ADRs under `docs/decisions/`, numbered `NNNN-slug.md`, with number, title,
date, status, context, decision, and consequences.
- Journal entries are `docs/journal/YYYY-MM-DD-slug.md` with a `date:` field in the frontmatter.
The sidebar sorts on it. Undated files in that directory are tracking documents, not entries.
The sidebar sorts on it. Tracking documents (`open-questions.md`, `proposal-questions.md`,
`test-coverage-gaps.md`) have no date in the filename but do carry a `date:` field: the date of
their last substantive change. Update it whenever you change one.
`docs/journal/_drafts/` is never published.
- Nothing about employers, customers, or internal channels goes into `docs/`. Attribute feedback to
the proposal thread generically unless the name is already public there.
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ DB_PASS ?=
DB_HOST ?= 127.0.0.1

.DEFAULT_GOAL := help
.PHONY: help install lint lint-fix compat analyse test test-ms coverage reference reference-check ci clean
.PHONY: help install lint lint-fix compat analyse test test-ms test-examples coverage reference reference-check ci clean

help: ## Show this help.
@grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
Expand Down Expand Up @@ -45,6 +45,9 @@ test: ## Run the single-site suite.
test-ms: ## Run the multisite suite.
WP_MULTISITE=1 $(VENDOR_BIN)/phpunit -c phpunit-multisite.xml.dist

test-examples: ## Run the platform examples suite against emulators. Needs Moto (see examples/README.md); not part of make ci.
$(VENDOR_BIN)/phpunit -c phpunit-examples.xml.dist

coverage: ## Run the single-site suite with coverage.
$(VENDOR_BIN)/phpunit --coverage-html coverage --coverage-text

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ target list.
| `make compat` | PHPCompatibilityWP at `testVersion 7.4-` |
| `make analyse` | phpstan |
| `make test` / `make test-ms` | phpunit, single site / multisite |
| `make test-examples` | phpunit against `examples/*/tests`, needs Moto running (see `examples/README.md`); not part of `make ci` |
| `make coverage` | phpunit with an HTML coverage report (see `docs/journal/test-coverage-gaps.md` re: wp-env) |
| `make reference` / `make reference-check` | regenerate `docs/reference/` from docblocks / fail if it is stale |
| `make ci` | all of the above |
Expand Down Expand Up @@ -144,7 +145,9 @@ Nothing there is loaded by the plugin, and it's excluded from `make ci` so those
never become this project's. Read its README before writing one: a key-management service (AWS
KMS, Google Cloud KMS) is a `WP_Secrets_Keyring` and takes three methods, while a secret store
(Secrets Manager, Parameter Store) is a `WP_Secrets_Provider` and takes eight. People routinely
pick the wrong one and pay for it in per-operation API calls.
pick the wrong one and pay for it in per-operation API calls. Start from
[`examples/aws-kms-keyring/`](examples/aws-kms-keyring/) — it is the smaller interface, and it is
what most hosts are actually after: key custody moves to the KMS and nothing else changes.

## Contributing

Expand Down
78 changes: 68 additions & 10 deletions cli/class-wp-cli-secret-command.php
Original file line number Diff line number Diff line change
Expand Up @@ -487,42 +487,100 @@ public function migrate_legacy( $args, $assoc_args ) {
}

/**
* Re-wraps the root key under a new WP_SECRETS_KEY after a site-key change.
*
* No secret is re-encrypted: rotation only changes what the root key is
* Re-wraps the root key under the active keyring.
*
* There are two cases, chosen with --from. `--from=config-previous` (the
* default) is a site key change: the root key, currently wrapped under
* WP_SECRETS_KEY_PREVIOUS, is re-wrapped under the current WP_SECRETS_KEY.
* `--from=config` is moving the root key onto a new keyring: a secrets.php
* drop-in has installed one, and the root key, currently wrapped under the
* config keyring's WP_SECRETS_KEY, is re-wrapped under that new keyring. No
* secret is ever re-encrypted: rotation only changes what the root key is
* wrapped under, not the root key's own bytes.
*
* ## OPTIONS
*
* [--from=<keyring>]
* : Which keyring currently wraps the root key.
* ---
* default: config-previous
* options:
* - config-previous
* - config
* ---
*
* [--yes]
* : Skip the confirmation prompt.
*
* ## EXAMPLES
*
* $ wp secret rotate --yes
* $ wp secret rotate --from=config --yes
*
* @when after_wp_load
*
* @param array $args Positional arguments.
* @param array $assoc_args Associative arguments.
*/
public function rotate( $args, $assoc_args ) {
if ( ! defined( 'WP_SECRETS_KEY_PREVIOUS' ) ) {
WP_CLI::error( 'WP_SECRETS_KEY_PREVIOUS is not defined. Move the current WP_SECRETS_KEY value to WP_SECRETS_KEY_PREVIOUS, set WP_SECRETS_KEY to a new value from `wp secret generate-key`, then run this again.' );
$from = isset( $assoc_args['from'] ) ? $assoc_args['from'] : 'config-previous';

if ( ! in_array( $from, array( 'config-previous', 'config' ), true ) ) {
WP_CLI::error( sprintf( 'Unknown --from value "%s". Use config-previous or config.', $from ) );

return;
}

WP_CLI::confirm( 'Rotate the site key? This re-wraps the root key under the new WP_SECRETS_KEY.', $assoc_args );
$key_manager = _wp_secrets_get_key_manager();
$new_keyring = $key_manager->get_keyring();

if ( 'config-previous' === $from ) {
if ( ! defined( 'WP_SECRETS_KEY_PREVIOUS' ) ) {
WP_CLI::error( 'WP_SECRETS_KEY_PREVIOUS is not defined. Move the current WP_SECRETS_KEY value to WP_SECRETS_KEY_PREVIOUS, set WP_SECRETS_KEY to a new value from `wp secret generate-key`, then run this again.' );

return;
}

$result = _wp_secrets_get_key_manager()->rotate_site_key(
new WP_Secrets_Config_Key_Provider( true ),
new WP_Secrets_Config_Key_Provider( false )
if ( $new_keyring instanceof WP_Secrets_Config_Key_Provider
&& defined( 'WP_SECRETS_KEY' )
&& WP_SECRETS_KEY === WP_SECRETS_KEY_PREVIOUS
) {
WP_CLI::error( 'WP_SECRETS_KEY and WP_SECRETS_KEY_PREVIOUS hold the same value. There is nothing to rotate.' );

return;
}

$old_keyring = new WP_Secrets_Config_Key_Provider( true );
} else {
if ( $new_keyring instanceof WP_Secrets_Config_Key_Provider ) {
WP_CLI::error( 'The active keyring already reads WP_SECRETS_KEY. --from=config only applies after a secrets.php drop-in installs a different keyring.' );

return;
}

$old_keyring = new WP_Secrets_Config_Key_Provider( false );
}

WP_CLI::confirm(
sprintf(
'Rotate the root key from "%s" to "%s"? This re-wraps the root key; no secret is re-encrypted.',
$old_keyring->get_key_source(),
$new_keyring->get_key_source()
),
$assoc_args
);

$result = $key_manager->rotate_site_key( $old_keyring, $new_keyring );

if ( is_wp_error( $result ) ) {
WP_CLI::error( $result->get_error_message() );

return;
}

WP_CLI::success( 'Site key rotated. No secret needed to be re-encrypted.' );
WP_CLI::success(
sprintf( 'Root key re-wrapped under: %s. No secret needed to be re-encrypted.', $new_keyring->get_key_source() )
);
}

/**
Expand Down
7 changes: 4 additions & 3 deletions docs/decisions/0002-plugin-before-core-patch.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ description: "Why the Secrets API ships as a feature plugin first, and what fini
|---|---|
| **Number** | 0002 |
| **Date** | 2026-08-25 |
| **Status** | Accepted |
| **Status** | Accepted. Amended by [ADR 0008](0008-the-trac-ticket-replaces-thread-confirmation.md). |

## Context

Expand Down Expand Up @@ -39,8 +39,9 @@ the presence of the symbol, so a slip to 7.3 cannot silently disable it.

**The plugin is done when:**

- the public surface matches the proposal, with every addition beyond it recorded and confirmed
on the thread;
- the public surface matches the proposal, with every addition beyond it recorded and listed on
the Trac ticket ([ADR 0008](0008-the-trac-ticket-replaces-thread-confirmation.md); originally
"confirmed on the thread");
- `make ci` is green across the PHP 7.4 to 8.3 and single-site to multisite matrix;
- at least one real platform provider has been built against `WP_Secrets_Provider` and the
conformance suite, and what it turned up has been fixed;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
title: "ADR 0008: The Trac ticket replaces thread confirmation"
description: "Additions beyond the proposal are reviewed on the Trac ticket rather than waiting for confirmation on the make/core thread. Before the ticket opens, the extension points get two more implementations and the CLI gets a real end-to-end test."
---

# ADR 0008: The Trac ticket replaces thread confirmation

| | |
|---|---|
| **Number** | 0008 |
| **Date** | 2026-09-24 |
| **Status** | Accepted. Amends [ADR 0002](0002-plugin-before-core-patch.md). |

## Context

[ADR 0002](0002-plugin-before-core-patch.md) says the plugin is done when every addition beyond
the [proposal][proposal] has been "recorded and confirmed on the thread". Those additions are
`wp_retire_secret_version()`, `wp_list_secrets()`, the network functions, the provider and keyring
interfaces, and the rest listed on the [scope](../spec/scope.md) page.

Since 0.1.0 was tagged on 4 September, the proposal, the plugin, and a documentation site
listing each addition with its rationale have been put in front of contributors on the make/core
thread, in Core Slack, and in core dev chat. The response has been consistent support and no
critical feedback. No public issue has been opened, and no one has objected to a name.

That is silence rather than confirmation, and waiting longer will not change it. A make/core post
gets read, not reviewed. Committers review Trac tickets. 7.2 Beta 1 is scheduled for 20 to 22
October, and a patch that opens in mid-October leaves no time for that review to change anything.

## Decision

Opening the Trac ticket replaces thread confirmation as the review step for additions beyond
the proposal.

- The ticket description lists every addition by name, each with a link to the spec page that
explains it, so review can reject a name specifically rather than approve the patch as a whole.
- The rounds of iteration that were planned for the thread happen as patch revisions on the
ticket. The plugin follows every revision to its surface, so the plugin and the patch stay the
same code.
- ADR 0002's first "plugin is done" criterion now reads: the public surface matches the proposal,
and every addition beyond it is recorded and listed on the Trac ticket.

Before the ticket opens, three pieces of work test the surface in ways silence cannot:

1. **A KMS-backed keyring example.** The documentation recommends it as the first integration a
host should write, and none exists. It is the first real test of `WP_Secrets_Keyring`, and of
how an existing site moves onto a new keyring.
2. **A HashiCorp Vault provider example.** Vault numbers versions with integers rather than
keeping two slots. It is the first backend that does not already share the
`CURRENT`/`PREVIOUS` shape, which is the design most likely to be wrong in a way nobody has
pointed out.
3. **A WP-CLI smoke test against a real `wp` binary.** This is the only coverage gap marked as
needing an answer before the core patch. The WP-CLI surface is part of what 7.2 ships, and
three dispatch bugs have already got past a green suite.

ADR 0002's third criterion, one real platform provider, is already met by the AWS Secrets Manager
example, which was verified against live AWS on 4 September.

## Consequences

- A name can still change after the ticket opens. A rename then costs a patch revision and a
plugin release rather than an edit to a proposal, which is still cheap before Beta 1.
- Silence could mean no one read the additions closely. Listing each addition in the ticket
description, rather than leaving reviewers to diff the patch against the proposal, is the
mitigation.
- The three examples and the smoke test come before the ticket. If one of them changes an
interface, the ticket opens with that change already made.
- There is less time for the ticket itself. Work on the examples is limited to what tests the
interfaces, and anything beyond that waits until after Beta 1.

[proposal]: https://make.wordpress.org/core/2026/08/25/proposal-a-secrets-api-for-wordpress-7-2/
55 changes: 55 additions & 0 deletions docs/decisions/0009-root-key-cached-for-the-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: "ADR 0009: Root key cached for the request"
description: "WP_Secrets_Key_Manager unwraps the root key at most once per request, keyed on the stored wrapped value, so a remote keyring pays one round trip per request instead of one per secret."
---

# ADR 0009: Root key cached for the request

| | |
|---|---|
| **Number** | 0009 |
| **Date** | 2026-09-24 |
| **Status** | Accepted. |

## Context

`WP_Secrets_Key_Manager::get_root_key()` unwrapped the root key on every call, and every master-key
derivation called it. A request reading ten secrets across site and network scope unwrapped the
root key ten times. For the default `WP_Secrets_Config_Key_Provider`, an in-process derivation,
that cost is trivial. For a keyring backed by a KMS or HSM, each unwrap is a network round trip
against a service billed per call, and `examples/README.md`'s "Start with a KMS keyring" section
already claimed the opposite: that a KMS "gets called once per request at most instead of once per
secret." That claim was aspirational, not built.

Writing the KMS keyring example first, as [ADR 0008](0008-the-trac-ticket-replaces-thread-confirmation.md)
schedules, surfaced this before the claim shipped to reviewers. The option considered and rejected
was to leave caching to each keyring implementation: every host writing a `WP_Secrets_Keyring`
would then have to build its own request-scoped memoisation to be usable at any real secret count,
each a fresh chance to get the "memory only, never the object cache" rule wrong.

## Decision

`WP_Secrets_Key_Manager` caches one unwrapped root key for the life of the object, which
`_wp_secrets_get_key_manager()` makes the life of the request. The cache is keyed on the stored
wrapped value, not on time or call count: `get_root_key()` serves the cached bytes only while
`get_site_option( ROOT_KEY_OPTION )` still returns the exact value the cache was unwrapped from. A
rotation, a re-wrap, or a restore changes that stored value, so the next `get_root_key()` unwraps
again rather than serving stale bytes. Root-key generation and `rotate_site_key()` both prime the
cache with the value they just produced, at no extra unwrap cost. An unwrap error is never cached,
so a transient failure does not stick for the rest of the request. The cache never touches
`wp_cache_*`, a transient, or any option other than `ROOT_KEY_OPTION`.

## Consequences

- One unwrapped copy of the root key lives in the key manager object for the request, in memory
only. The class docblock says so plainly, since this is now load-bearing behavior a reviewer
needs to see without reading the method bodies.
- The memzero discipline for callers of `get_root_key()` is unchanged: they still receive a copy
and are still responsible for zeroing it. The key manager's own cached copy is not theirs to
zero, and PHP's copy-on-write semantics mean a caller zeroing their copy cannot corrupt the
cached one.
- A remote keyring now costs one call per request rather than one per secret, which is what
`examples/README.md` already claimed before this existed.
- The cache is per key-manager instance. `_wp_secrets_get_key_manager()` already builds exactly one
per request via a static local, so no new global or lifecycle concept is introduced.
- This lands in the Trac patch alongside the rest of the key manager; it is not a follow-up.
Loading
Loading