All notable changes to brama are documented in this file.
The format is based on Keep a Changelog 1.1.0, and this project adheres to Semantic Versioning.
See docs/agents/changelog.md for how entries are written.
- Decide every column and key no generator claims in
brama anonymize init. In a terminal each one is a question: leave it unclassified,drop, afake.<generator>that can fill it, orkeep. Enter alone leaves it, andqstops asking and keeps what you answered.--dry-runasks nothing. (#9) - Cover a multisite network's six site-global tables in the
wordpresspreset:wp_blogs,wp_site,wp_sitemeta,wp_blogmeta,wp_signupsandwp_registration_log, named under your own table prefix, plus thespamanddeletedcolumns multisite adds towp_users. A multisite install whose tables and keys are core's own no longer refuses. The network'sadmin_emailand each signup's and registration's email are fabricated; salts, signup activation keys, signupmetaand registration IPs are dropped. (#74) - Drop
wp_sitemeta.meta_key=site_adminsin thewordpresspreset, so a multisite copy has no super admin: the list holds real logins that match no fabricated account. Each site's administrators still work. Runwp super-admin add <login>on the copy for network access. (#74) - Classify discriminator values with a number placeholder:
{n}in akeysentry matches one run of digits, sowp_{n}_capabilitiescoverswp_2_capabilitiesandwp_403_capabilitiesbut notwp_admin_capabilities. An exact key still wins, and a{n}entry wins over any*prefix. It works in a hand-writtenbrama.yamland may end in*. A key holding two{n}, or any other{name}such as{prefix}, makes the file invalid, exit 1. (#72) - Cover every site's copy of the prefixed
usermetakeys in thewordpresspreset —wp_2_capabilities,wp_3_user_level— so a multisitewp_usermetano longer refuses over them. (#72) - Classify discriminator values in
brama anonymize initthe way columns are classified. A key a generator claims by name, likebilling_email, is written asfake.<generator>, and a key none claims is left out and unclassified. Only exact keys are written, never a*pattern.--jsoncounts them askeysandunclassified_keys. (#71, #8) - Write a key's
discriminatorandvaluefrom the preset when the file's table does not name them yet, so a key can be classified in a table only the preset keys. (#71, #8) - Apply a key a generator claims in
brama anonymize reviewwithout asking, and list it, in--jsontoo asapplied_new_keys. (#71, #8) - Hand back every key nothing classifies from
brama anonymize review. In a terminal each one is on every destination's list, and ticking it keeps the key and approves it for that destination. With nobody there the run exitsreview_requiredand names each key underpending_unclassified_keys. A key that itself holds a*is never offered or written, because the file would read it as a prefix. You decide it inbrama.yaml. (#71, #8) - Refuse every discriminator value nothing classifies: where
anonymize checkreads an environment's schema it also reads the keys each discriminator holds, and exits 42 naming every key nokeysentry matches —wp_usermeta.meta_key='stripe_customer_id' has no classification. Keys are compared as bytes, soBilling_Emailis notbilling_email, and a NULL or empty key is named"", as the file spells it. Where no environment is reachable,checksays key coverage went unverified beside column coverage.--jsonreports the keys read asschema_keys. The keys are read, never the values they select. (#69, #8) - Classify discriminator values by prefix: a
keysentry ending in*—_transient_*— covers every key starting with what comes before it, so a new transient hash no longer leaves a key unclassified. An entry naming the key exactly wins, then the longest prefix.anonymize checkrefuses a*anywhere else in a key and names the prefix to write. (#67, #8) - Classify the rows with no discriminator value, NULL or empty, under the key
"". (#67, #8) - Approve a prefix entry as written:
wp_options.option_name=_transient_*. An approval names the entry, not a key under it;checkrefuses…=_transient_abcand names the prefix to approve instead. (#67, #8) - Match the discriminator keys that carry
$table_prefixagainst your project's own prefix too. WordPress writes$table_prefix+capabilitiesintousermeta, so an install onacme_holdsacme_capabilities, and the preset classifies it there rather than leaving it unclassified and refusing the pull. The prefix is still read out of the project on every run and never written tobrama.yaml. (#68, #8) - Match the
wordpresspreset against your project's own$table_prefix, so an install onacme_gets the preset an install onwp_always got. The prefix is read out of the project's own config —wp-config.php, or.envandconfig/application.phpon Bedrock — every time the preset is resolved, and is never declared inbrama.yaml, where it would be a second copy of the truth and the one nobody updates. (#58, #7) - Refuse a prefix brama cannot read, naming the files it looked in and the preset waiting
on the answer. It never falls back to
wp_: a preset applied under a guessed prefix does not classify nothing — it classifies whatever table sorted into the accounts table's place. (#58, #7) - Approve one discriminator value, in
environments.<name>.anonymize.approved:wp_usermeta.meta_key=admin_colorbeside thetable.columnform. A key is classified one at a time, so it is approved one at a time — approving the column holding its value would approve every other key at once, which is why that is still refused. The interactive review offers discriminator values like any other pending keep. (#59, #7) - Answer the pending half in a terminal.
brama anonymize reviewnow puts what only you can decide as a checklist — one per destination environment, one line per column — and the question it asks is "do you approve exposing this column as real data?" rather than "keep this column?". Nothing starts ticked. (#57, #7) - See what declining writes before you decide it: each line shows the classification that stands if you leave it unticked, so nothing is a leap. A column no generator claims says so, and is left for you. (#57, #7)
- Tick a line and the approval is recorded under that destination's
environments.<name>.anonymize.approvedand no other. Leave it unticked and the classification the line showed is written toanonymize.tables— unless another destination approves the same column, which leaves thekeepstanding and sends the fabricated value to the destination that declined. That holds for a destination this run never asked about, so--env localcannot revoke an approval staging already had. (#57, #7) - Decide a preset's keeps once: they arrive as a single line naming how many columns it is about, openable to the full list before you answer it. A hundred checkboxes before anybody has pulled anything makes accept-all the only realistic answer, which authorizes exactly as blindly as trusting the preset would have. (#57, #7)
- Accept or hold a preset's loosenings as their own question, asked after the destinations and never mixed into one of their lists. (#57, #7)
--non-interactive, on every command, for a person who wants the run an agent gets. It refuses to ask rather than skipping a guardrail: the answer brama takes when nobody answers is always the narrower one.--json, a pipe, and no terminal do the same. (#57)- Reconcile a classification with
brama anonymize review, the one command that writes one. With nobody at the keyboard it does the half of the job that is not a decision and hands back the half that is, which is the whole of what a CI runner or an agent gets. (#56, #7) - Apply and write down what narrows what leaves production: the columns a preset now classifies more strictly than your file does, and the columns a migration added that a generator declares a claim on. Neither needs anybody's approval, and both are listed so that nothing lands invisibly. The comments, the key order and every line the change is not about survive the write, and a run with nothing to apply writes no bytes at all. (#56, #7)
- Hand back what widens it, applying none of it: the columns a preset would loosen, and
the kept columns a destination approves nothing for. Each is named, and the run exits
review_requiredat 42 — nothing went wrong, and the work left is work only you can do. A run with nothing waiting exits 0. (#56, #7) - Read both lanes in
--jsonunderapplied_preset_tightenings,applied_new_columns,pending_preset_looseningsandpending_keeps, withreasoncarrying the exit. All four keys are always present, and an empty list means nothing was found rather than nothing was looked for. (#56, #7) - Resolve what each environment would actually receive, from its
environments.<name>.anonymize.approvedlist read together with the classification. An approval counts for that environment and no other, so the samekeepcolumn can send real values to staging and fabricated ones to a laptop. (#55, #7) - Send a kept column the destination has not approved as the value its generator would have produced, rather than as the real one. A pull can only ever expose less than the file suggests, never more. (#55, #7)
- Refuse instead, with reason
no_fallbackand exit code 42, where no generator claims the column: emptying a column brama could not name is a decision only a human makes, and the message names the column and the three ways out of it. (#55, #7) - Report both in
brama anonymize check, per environment and listed apart — the kept columns a generator stands in for, and the ones that would stop a pull.--envnarrows the report to one destination, and a run with no--envcovers every one. A run that finds a column with no fallback is reported aspartial. (#55, #7) - Name them in
--jsonunderkeep_substitutedandkeep_no_fallback. Both keys are always present, and an empty list means every kept column resolves to what the file already says. (#55, #7) - Apply a preset's answer over your own where the preset is the stricter of the two, so
a preset brama tightens in a later release protects a column in every project that
names it — including the projects whose
brama.yamlstill sayskeep. (#54, #7) - Hold a preset's answer where it is the looser of the two. A preset that starts keeping
a column your file fabricates never widens what leaves production on its own: your
file's answer stands until
brama anonymize reviewaccepts the change. (#54, #7) - Report both in
brama anonymize check, listed apart — the columns brama is already anonymizing more strictly than the file says, and the ones it is holding — so neither reads as the other. A run that finds either is reported aspartial. (#54, #7) - Name the drifted columns in
--jsonunderpreset_drift_appliedandpreset_drift_held, so a CI job can report which column changed instead of only that something did. Both keys are always present, and an empty list means the preset and your file agree. (#54, #7) - Accept an approval of a column a preset has since tightened, rather than refusing the
run over it. Approving a column your file keeps is not a mistake, and a tightening that
stopped the pull it exists to make safe would be the wrong way round —
brama anonymize checksays the approval sends nothing while the tightening is applied. (#54, #7) - Compare a preset against your file only where your file has an answer. A project that has recorded no classification has nothing for the preset to disagree with, and every preset answer applies as written. (#54, #7)
- Classify a project with
anonymize.preset, which names a classification brama ships for a framework it knows rather than spelling it out.tables:then holds only what is specific to your project and the overrides you meant, so the block stays short enough to read in a diff. (#53, #7) - Ship a
wordpresspreset covering the twelve tables a default WordPress install creates — fabricating the account and comment-author details, dropping live password-reset tokens and login sessions, and keeping the site's own content and configuration. (#53, #7) - Reference the preset rather than copying it, so a preset brama tightens in a later
release reaches your project without anyone editing
brama.yaml. (#53, #7) - Let
anonymize.tablesoverride a preset one column at a time. Writing a column of a table the preset knows changes that column and leaves the rest of the table to the preset. An override that would send more real data than the preset does is held for review rather than applied. (#53, #7, #54) - Write
preset: <name>frombrama anonymize initwhen brama ships one for your adapter, and leave the columns it already answers for out of the file. A preset is a deliberate statement about a table brama knows; a generator's name pattern is an inference, and the specific one wins. (#53, #7) - Count the columns a preset classifies as classified in
brama anonymize check, without expanding them into the file — so a check against a reachable environment can now report full column coverage on a project that names one. (#53, #7) - Refuse an
anonymize.presetnaming a preset brama does not ship, listing the ones it does. A name that resolved to nothing would silently unclassify every column the preset was carrying. (#53, #7) - Grant no approval from a preset. A preset says what a column holds and never who
may receive it, so a column it classifies
keepstill sends real values nowhere until a human approves it for that environment. (#53, #7) - Add
brama anonymize init, which reads an environment's schema and writes the classification for every column a generator claims — by name and by type — asaction: fake.<generator>. It is the filebrama initdeliberately does not write. (#52, #7) - Leave every column no generator claims out of the file entirely, rather than
defaulting it to
drop. Dropping is safe about privacy and reckless about everything else, and zeroingorders.total_amountis a product decision brama has no standing to make about data it could not name. What is omitted stays unclassified, which already refuses a pull. (#52, #7) - Never write
keepfromanonymize init. Keeping a column sends real production data, and that decision entersbrama.yamlonly where a human put it. (#52, #7) - Report
brama anonymize initaspartialwhile anything is still unclassified, and name each column it left for you, so the firstbrama anonymize reviewhas a list to work from. (#52, #7) - Refuse to overwrite an anonymize block that already exists, pointing at
brama anonymize review— which changes decisions one at a time instead of replacing reviewed ones wholesale. (#52, #7) - Write the block into
brama.yamlline by line, so comments, key order and the aligned commentsbrama initwrote survive the edit untouched. (#52, #7) - Compare the classification against the schema of a reachable environment, and name
every column the database has that
brama.yamlsays nothing about. Only a schema can say a column is there at all, so this is the half ofbrama anonymize checkthat a CI runner cannot answer. (#51, #7) - Refuse a
fake.<generator>that cannot fill the column it was given —fake.emailon avarchar(20), or on anint— while the file is still open in an editor, rather than partway through a dump on a production server. (#51, #7) - Report
brama anonymize checkaspartialrather thansuccesswhen no environment was reachable, naming the column coverage it could not verify. A clean run against no schema is not a clean bill of health, and a caller reading only the status must not take one for the other. (#51, #7) - Add
brama anonymize check, which validates the classification inbrama.yamland exits 42 when it does not hold together. It writes nothing and reaches no database, so it runs on a CI runner with no route to production. (#50, #7) - Refuse a
fake.<generator>naming a generatorbramadoes not have, listing the ones it does. (#50, #7) - Refuse
correlatebesidekeepordrop, where it decides nothing: real values already correlate, and a dropped value has nothing to relate. (#50, #7) - Refuse a correlation group with one member — the guard against
correlate: custmerquietly becoming a group of its own — and suggest the near-miss group name when there is one. (#50, #7) - Refuse an approval of a column that is not classified
keep, or that nothing classifies at all. Approval only means something where real values would otherwise be sent. (#50, #7) - Check every environment's approvals by default, and narrow to one with
brama anonymize check --env <name>. There is no--ciflag:--jsonis the machine-readable contract. (#50, #7) - Classify a column in
brama.yamlwithaction, which isfake.<generator>,keep, ordrop.fakeon its own is refused: naming the generator is part of the decision. (#48, #7) - Add
correlatebesideaction, which names the group a column shares one fabricated identity with, so the joins between its members survive. (#48, #7) - Add
environments.<name>.anonymize.approved, a list oftable.columnentries naming the columns that environment may receive as real data. Approval is per destination: approved for staging is not approved for local. (#48, #7) - Add
anonymize.tables.<table>.columns, so a table that classifies bydiscriminatorandkeyscan classify its ordinary columns in the same entry. Previously it could do one or the other, leaving a column such aswp_usermeta.umeta_idnowhere to be classified. (#48, #7) - Add
anonymize.tables.<table>.value, naming the column holding the value adiscriminatorselects for. (#48, #7) - Reject the shorthand
email: fake.emailwhere a column is expected, naming the column and theaction:line that replaces it. A column is always an object. (#48) - Add
make testenv-up, which starts two containerised servers running realsshdforbrama server addand the shim install to be tested against. (#41) - Publish
bramaforlinuxanddarwin, onamd64andarm64, as an archive per release. Each release carries a checksum file, an SBOM per archive, and a build provenance attestation, so a download can be traced to the commit and the workflow that produced it:gh attestation verify <file> --repo bramaos/brama. - Upgrade the shim on a server to the version of
bramareaching it, as a step of its own before anything else runs, never partway through. (#4) - Name both versions when the shim moves —
shim upgraded 0.1.0 → 0.1.1— so the step says what changed and not only that something did. (#4) - Replace a shim
bramais not newer than instead of stopping, which is what a server another developer reached with a newerbramaleaves behind. (#4) - Add
--dry-runtobrama server add, which reports the shim it would install or upgrade and writes nothing, on the server or inbrama.yaml. It still connects: which shim a server already runs cannot be answered frombrama.yaml. (#4) - Add
brama server add <name> --host <host> [--user <user>], which registers a server and installs the shim on it. Authentication is OpenSSH's:--hostis passed through untouched, so it may be a hostname, an IP, or a~/.ssh/configalias, and identity comes from ssh-agent. brama stores no credentials. (#2) - Add the shim, installed at
~/.brama/shimon a server. Builds forlinux/amd64andlinux/arm64are embedded inbramaand streamed down the connection already open, so nothing is fetched over the server's own network. This build answers--version. (#2, #3) - Add
brama version, which prints the version the binary was built from. - Add
brama init, which detects the adapter and writes a startingbrama.yaml. It writes no credentials and no classification —brama anonymize initowns theanonymizeblock, and until it runs every column is unclassified. (#1) - Add the
brama.yamlschema:version,app.adapter,app.paths,environments,servers, andanonymize. Unknown keys are rejected rather than ignored, so a typo cannot silently mean "no classification". (#1) - Add
--jsonand--dry-runtobrama init.--dry-runshows the file it would write and writes nothing. - Add
--adaptertobrama init, to name the adapter when detection cannot. - Add
brama --version, alongside the existingbrama versionsubcommand. - Add
--helpfor every command, with generated manpages and shell completions.
- BREAKING:
brama anonymize checkrefuses with exit 42 (unclassified) when the schema it reads has a columnbrama.yamldoes not classify, instead of reportingpartialand exiting 0. One refusal names every unclassified column and then every unclassified key,wp_posts.secret has no classification, so one run lists them all.--jsonno longer hasunclassified_columns, because a run that gets far enough to report always has zero. A CI job that rancheckagainst a reachable environment should expect 42 until each column it names has an action inbrama.yaml. Offline runs are unchanged. (#10) - BREAKING:
brama anonymize initexits 42 (unclassified) when it leaves anything unclassified, instead of 0. With--non-interactive,--jsonor no terminal it still writes what generators claim and reportspartial, but a script can now tell from the exit code that a pull will refuse. A script that runs init unattended should expect 42, then give each column it names an action inbrama.yaml. A schema nothing claims, with no preset and nobody to decide it, is now a refusal (42) too, not an error (1). (#9) - Count two more classified columns in
wp_usersinbrama anonymize checkon a single-site WordPress install: thewordpresspreset now names multisite'sspamanddeleted. With no schema in reach, the preset counts eighteen tables, not twelve. (#74) - BREAKING: the
wordpresspreset classifieswp_postmetaandwp_optionsper key instead of keepingmeta_valueandoption_valuewhole.meta_valueholds a thumbnail ID and a cached oEmbed response at once, andoption_valuethe site title and the mail server's password at once, so one answer for either column was never the right one. Both tables now name their discriminator, and the preset answers for core's own keys. A key a plugin writes is unclassified and refuses the pull until your project says what it holds. If you approvedwp_options.option_valueorwp_postmeta.meta_valuefor an environment,brama anonymize checknow refuses that line — approve the keys you mean instead, as inwp_options.option_name=mailserver_url. (#70, #8) - Drop
_transient_*and_site_transient_*fromwp_options, and_oembed_*fromwp_postmeta. They are caches that regenerate on their own, and some hold a remote response that brought personal data in with it. (#70, #8) - Classify the options holding a credential or a live address as what they are rather than
keeping them:
admin_email,new_admin_emailandmailserver_loginare faked, andmailserver_pass,ftp_credentialsandrecovery_keysare dropped.siteurlandhomeare still kept — they are the site's own address, not a person's, and pointing them at the destination is the pull's job. (#70, #8) - Add
shim_change,shim_previous_versionanddry_runtobrama server add --json.shim_versionis now the version the server runs when the command finishes, which under--dry-runis the one it was already running. (#4) bramanow exits1rather than2for an unknown command or a bad flag.- Error messages now name the step that failed:
reading brama.yaml: permission deniedrather thanpermission denied. brama anonymize checknow names the keyed form to write when an approval names a discriminator or the column it selects for, instead of only refusing it. It also reports a keyed approval naming a key nothing classifies, the wrong discriminator, or a table with no discriminator at all. (#59, #7)
- Record a declined discriminator key in a table your
brama.yamldoes not classify yet, writing the table'sdiscriminatorandvalueabove it.brama anonymize reviewon a WordPress project failed with "brama.yaml does not classify wp_options" instead of writing the answer it had just been given. (#70, #8) - Stop
brama anonymize checkcounting a preset table the database does not have. Where a schema was read, the counts are of the tables that schema has, so a project the preset half-fits no longer reads as one with twelve tables classified. With no schema in reach nothing narrows the count, because nothing knows what is there. (#58) - Report a failed write instead of finishing successfully, so a closed pipe or a full disk is no longer silent.
- Let a stock WordPress project reach exit 0 on
brama anonymize review. The eighteenwp_usermetakeys the wordpress preset classifieskeepcould not be approved or declined by anybody, so the run exited 42 on a project where every decision had been made. (#59, #7)
- Update
golang.org/x/textto 0.39.0, closing an infinite loop on malformed input thatbramacould reach while rendering styled output (GO-2026-5970).