Repository navigation
Commit 758c77d
fix(metadata-protocol): a seed replay no longer rewrites every seeded row that carries a password or secret field on every boot (#24696)
Fixes #24624
Clause-②: no (a replay comparison: no accept set, served body or masking
verdict moves; the loader takes no new read and never sees a stored
credential)
## What this changes
Ruling 6278934212 on #24624 records letter B of analysis 6275762852 (the
maintainer, verbatim: 「同意」): **a masked field never blocks the replay's
skip.**
- `packages/metadata-protocol/src/seed-loader.ts`: the replay read
(`engine.find`, `isSystem: true`) serves a set `secret` field, and a set
`password` field on an object not managed by better-auth, as
`SECRET_MASK` (ADR-0100). `isNoOpReplay` compared the seed's plaintext
with that mask, read the row as changed, and rewrote it on every boot.
It now skips a field when all three hold: the object declares the field
masked on read, the existing row read back `SECRET_MASK`, and the seed
sets a value.
- `isNoOpReplay` stays a pure predicate, because `decideWriteAction`
asks it up to three times per record (the refusal check, the early
signal and the decision). The skip arms carry the uncompared fields, and
the load records them only where a skip is counted.
- One `info` line per load, when a replay skipped at least one row
without comparing a credential. It names every such field, the rule, and
both remedies (change another field of the row, or rebuild the
database). Text from the real-engine reading below:
> [SeedLoader] 1 seeded row(s) were left unchanged without comparing
their credential field(s) showcase_field_zoo.f_password: a secret field,
and a password field on an object not managed by better-auth, is masked
on read, so a replay cannot see whether the seed changed a value that is
already set and never rewrites a row for that alone. A seed change to
only such a field is not applied on replay: change another field of the
same row too (the rewrite then writes the seed's value), or rebuild the
database.
- What this PR does not do: take a new unmasked read, change
`ObjectQL.maskSecretFields` or `collectMaskedReadFields`, add an export,
edit an ADR, or add a dependency.
## How the loader knows a field is masked (dispatch mechanism assumption
2)
- **(b) `collectMaskedReadFields` from `@objectstack/objectql`:
impossible.** `packages/objectql/package.json` already lists
`@objectstack/metadata-protocol` under `dependencies` (`workspace:*`),
so the import would create a dependency cycle.
- **(a) the mask alone (`existing[key] === SECRET_MASK`)** fixes the
showcase. But it would also skip any non-credential field that happens
to store the eight-bullet string, so a seed change to that field would
be dropped silently. Ablation leg 2 below measures this: the pin `a
field the declaration does not mask keeps comparing, even when it holds
the mask's text` goes red.
- **Used: both, each read from its single source.** The field check asks
`isMaskedOnReadFieldType(type, managedBy)` from
`@objectstack/spec/data`. That is the declaration
`collectMaskedReadFields` itself asks, and `@objectstack/spec` is
already a dependency of this package. The value check reads
`SECRET_MASK` from the same module. The loader contains no copy of the
"secret, or password on a non-better-auth object" rule.
## One reading of the ruling for the seat to confirm: presence still
counts
The ruling's execution text says `isNoOpReplay` "skips the fields
`collectMaskedReadFields` collects". This PR skips such a field **only
while its value is set**. The mask preserves presence: an unset
credential reads back `null`, never the mask (`SECRET_MASK`'s docblock,
ADR-0100). So a `null` read is a stored fact the replay can compare:
- a seed that sets a credential on a row that has none is written. Under
the literal reading it would never be written unless another field
changed too;
- a seed that sets a credential to `null` over a set one is written,
which clears it.
Neither case churns, because the next replay reads the same presence and
skips. Ablation leg 3 swaps in the literal reading (`return
comparands.masked.has(key);`), and it turns only the presence pin red.
If the seat reads the ruling literally, that one-line change is the
entire difference.
## Notice level: `info`, and what that costs
- Why `info`: this skip is the declared, ruled outcome, and it fires on
every healthy replay of a seed that carries a credential (the stock
showcase, every boot). The precedent is `filterByEnv`'s skip line in the
same file, which logs at `info` "rather than crying wolf on every
production boot". The official examples also boot with zero warnings
(`examples/app-showcase/test/no-startup-warnings.test.ts`). Nothing the
system claims to hold is lost, because the row keeps its credential, so
this is not AGENTS.md's durability class.
- The cost, measured: the CLI's default kernel level is `warn`
(`packages/cli/src/utils/log-level.ts`). The boot-quiet window also
drops in-window `info` whatever `--log-level` says
(`BOOT_DIAGNOSTIC_FLOOR = 'warn'`,
`packages/cli/src/utils/boot-log-capture.ts`). So on an `os dev` or `os
serve` boot the line is not on screen. It reaches a host logging at
`info` outside that window: hot reload, package apply, draft publish,
marketplace install and per-organization replay. If the seat wants the
line visible at boot, it is a one-word change to `warn`, at the price of
a warning on every showcase boot.
## Tests (all at `a947341b91`, after merging `origin/main` `92ce3af404`)
- New pins in
`packages/metadata-protocol/src/seed-loader-replay.test.ts`, block `seed
replay — a credential the read path masks never blocks the skip`. The
engine double wraps the existing faithful engine. It stores a `password`
verbatim and a `secret` as an opaque ref, stamps `updated_at` on every
write, and masks on read through `isMaskedOnReadFieldType` (returning
copies). Both write paths are covered: batched `decideWriteAction`, and
sequential `writeRecord` through a self-referencing object.
- an unchanged replay issues no update, and the store, `updated_at`
included, is deep-equal (both paths × `upsert`/`update`);
- a credential-only change (`password` and `secret`) is not rewritten,
and the load emits exactly one notice, at `info`, naming every
uncompared field;
- a change to another field rewrites the row: the stored `password`
becomes the seed's new value, and the `secret` update carries the new
plaintext;
- setting an unset credential, and clearing a set one, are both written;
- a field the declaration does not mask keeps comparing even when it
holds the mask's text;
- no notice when no skipped row left a credential uncompared.
- Red first. Before the fix (`bfc1539fe0` source), 10 of the new pins
failed: every row carrying a credential was rewritten (3 of 4 rows
updated on an unchanged replay).
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: `Test Files 249 passed | 3 skipped (252)`, `Tests 20793
passed | 19 skipped (20812)`, verify-lock `VERDICT command-exit 0`.
- `pnpm --filter @objectstack/metadata-protocol typecheck`: exit 0. `tsc
--listFiles` includes `seed-loader-replay.test.ts`.
- Ablation, one-shot, through `scripts/ablation-replace.mjs` (anchor
hits 1, mutation proven on disk by count and blob, restored to the HEAD
blob `3963a4b4` with `git diff HEAD` empty after each leg). The test
imports `./seed-loader.js` from `src`, so no `dist/` sits on the path.
Expected directions were declared first, and all three held:
1. delete the mask branch in `isNoOpReplay`: 10 failed | 15 passed (the
replay, credential-only, other-field and presence pins);
2. value-only (`return stored === SECRET_MASK && seedValue != null;`,
i.e. option (a) alone): 1 failed | 24 passed (the literal-bullets pin
only);
3. declaration-only (`return comparands.masked.has(key);`, the literal
reading): 2 failed | 23 passed (the presence pin, both paths).
## Real-engine reading (scratch harness, not committed)
Setup: real `ObjectQL` with `SqlDriver` (better-sqlite3) on one SQLite
file. Every `examples/app-showcase` object definition, plus
`sys_business_unit`, and every showcase seed dataset (19 datasets, 132
rows). One process per "boot"; the loader runs from this branch's
source.
| run | inserted | updated | skipped | `Specimen — Full` stored
`f_password` | its `updated_at` | notices |
|:--|--:|--:|--:|:--|:--|--:|
| boot 1 (fresh file) | 132 | 0 | 0 | `hunter2` (plaintext at rest;
`engine.find` reads the mask) | `…23:17:00.189Z` | 0 |
| boot 2 (same seed) | 0 | **0** | 132 | `hunter2` | unchanged | 1 |
| password-only change (`hunter3`) | 0 | 0 | 132 | `hunter2` | unchanged
| 1 |
| `hunter3` + `f_phone` changed | 0 | 1 | 131 | `hunter3` | moved | 0 |
| same seed, mask branch ablated (the pre-fix comparison) | 0 | 1
(`Specimen — Full`) | 131 | — | moved | 0 |
| same seed, restored | 0 | 0 | 132 | — | unchanged | 1 |
This confirms dispatch mechanism assumption 1 on the real engine:
`f_password` is stored as plaintext, and the seed's own system-context
read returns `SECRET_MASK`.
## Gates
- **Derived list, run locally at `a947341b91`:** `node
scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 64 families, with the change set
taken from git by the tool. All 64 exited 0, each exit captured before
any pipe.
- **Reconciliation:** `--ran` reports `64 derived, 64 run, 0
NOT-MEASURED, 0 UNRUN`, a derived zero because every line carries its
exit code. On the pre-merge head `20abbaf71d`,
`check:dual-build-cjs-loads` printed `PREREQUISITE NOT MET` (not every
package had a `dist/`). At `a947341b91` it measured green: 111 require
entry points across 69 packages.
- **Not run locally:**
- `node scripts/check-platform-checklist.mjs --changed-from
origin/main`: no export was renamed or removed.
- `test:repo`: the diff touches no `repo`-project test. CI's `Test Core`
runs it.
- **CI-owned families:** read from this PR's check-runs, not re-run
here.
## Acceptance notes
- Changeset: `.changeset/24624-replay-masked-field.md`,
`@objectstack/metadata-protocol` `patch`, carrying the `Clause-②: no`
line.
- Not measured: what `history` records on a churned replay. That
belonged to the first claim's measurement list, and this PR removes the
churn.
- The showcase seeds no `f_secret`: its seed comment says the seed path
has no CryptoProvider, so a `secret` value is refused fail-closed. The
`secret` half is therefore pinned through the double only, not read on
the real engine.
- Card #24562's acceptance measure was all 132 showcase rows unchanged
on a second boot. The harness above reads boot 2 as 0 updated and 132
skipped.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0ca8180 commit 758c77d
3 files changed
Lines changed: 441 additions & 22 deletions
File tree
- .changeset
- packages/metadata-protocol/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
8 | 9 | | |
9 | 10 | | |
10 | 11 | | |
| |||
491 | 492 | | |
492 | 493 | | |
493 | 494 | | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
| 661 | + | |
| 662 | + | |
| 663 | + | |
| 664 | + | |
| 665 | + | |
| 666 | + | |
| 667 | + | |
| 668 | + | |
| 669 | + | |
| 670 | + | |
| 671 | + | |
| 672 | + | |
| 673 | + | |
| 674 | + | |
| 675 | + | |
| 676 | + | |
| 677 | + | |
| 678 | + | |
| 679 | + | |
| 680 | + | |
| 681 | + | |
| 682 | + | |
| 683 | + | |
| 684 | + | |
| 685 | + | |
| 686 | + | |
| 687 | + | |
| 688 | + | |
| 689 | + | |
| 690 | + | |
| 691 | + | |
| 692 | + | |
| 693 | + | |
| 694 | + | |
| 695 | + | |
| 696 | + | |
| 697 | + | |
| 698 | + | |
| 699 | + | |
| 700 | + | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
| 719 | + | |
| 720 | + | |
| 721 | + | |
| 722 | + | |
| 723 | + | |
| 724 | + | |
| 725 | + | |
| 726 | + | |
| 727 | + | |
| 728 | + | |
| 729 | + | |
| 730 | + | |
| 731 | + | |
| 732 | + | |
| 733 | + | |
| 734 | + | |
| 735 | + | |
| 736 | + | |
0 commit comments