Repository navigation
Commit af32cf9
fix(lint): RLS predicate enforceability reports the per-request refusals the shape check cannot see (#20210)
Fixes #19951
Clause-②: no
`validateRlsPredicateEnforceability` judged an RLS predicate's shape
with every `current_user` value replaced by a placeholder, so a
predicate the runtime refuses because of a value passed `os validate`
cleanly and enforced nothing. The rule's reference pass now reports two
such classes as `rls-predicate-unenforceable` (`error`), each with a
pasteable rewrite that uses the predicate's own field and key:
1. **A `current_user` value of the wrong type for its position.**
Examples: a membership set under `==` / `!=` or handed to a string
method, a one-value key on the right of `in`, the bare `current_user`
object under `in` or a string method. The compiler refuses these on
every request and `RLSCompiler` drops the policy.
2. **A lowered comparand the shared filter faces refuse by ruling.** A
`null` list member or a `null` ordering bound. These compile, and the
RLS layer never runs the faces on its own filter, so the backend answers
with semantics the platform left undefined.
Only `packages/lint/src/validate-rls-predicate-enforceability.ts`, its
test and one changeset change. No runtime source is touched (formula
lowering, `RLSCompiler` and the shared comparand faces are unchanged).
## Shape table (Zone 2, item 1)
Lint readings come from a stack that declares the fields. Runtime
readings come from a throwaway probe (never committed) built on the
`rls-list-literal-comparand-fails-closed.test.ts` harness: real
`SecurityPlugin` + ObjectQL + `SqlDriver` on better-sqlite3, caller
`usr_member`, rows `r_open` (status open) and `r_closed` (status closed,
reviewer usr_member), and `check` inserts of one `open` and one `closed`
row. "main" is `560b724c`; "this PR" is `77060e6d`.
| authored predicate | lint on main | lint, this PR | `using` read
(runtime) | `check` write (runtime) |
|:--|:--|:--|:--|:--|
| `record.reviewer_id != current_user.org_user_ids` | 0 | unenforceable
| 0 rows, `DENY (fail closed)` WARN | 403 / 403 |
| `!(record.reviewer_id == current_user.org_user_ids)` | 0 |
unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id == current_user.positions` | 0 | unenforceable | 0
rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id in current_user.id` | 0 | unenforceable | 0 rows,
`DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id in current_user` | 0 | unenforceable | 0 rows,
`DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id.startsWith(current_user)` | 0 | unenforceable | 0
rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id.contains(current_user)` | 0 | unenforceable | 0
rows, `DENY (fail closed)` | 403 / 403 |
| `record.reviewer_id.startsWith(current_user.org_user_ids)` | 0 |
unenforceable | 0 rows, `DENY (fail closed)` | 403 / 403 |
| `record.status in ['open', null]` | 0 | unenforceable | `r_open` (acts
as `in ['open']`), no WARN | open admitted / closed 403 |
| `record.status in [null]` | 0 | unenforceable | 0 rows, no WARN | 403
/ 403 |
| `!(record.status in ['open', null])` | 0 | unenforceable | **0 rows**
(`r_closed` hidden), no WARN | open 403 / **closed admitted** |
| `record.status > null` | 0 | unenforceable | 0 rows, no WARN | 403 /
403 |
| `record.status <= null` | 0 | unenforceable | 0 rows, no WARN | 403 /
403 |
| `record.reviewer_id != current_user` | unenforceable (since #20189) |
unenforceable (unchanged) | 0 rows, `uncompilable predicate` WARN | 403
/ 403 |
| CONTROL `record.status != ['closed', 'archived']` | unenforceable |
unenforceable (unchanged) | 0 rows, `uncompilable predicate` WARN | 403
/ 403 |
| CONTROL `record.reviewer_id == current_user.id` | 0 | 0 | `r_closed` |
per value |
| CONTROL `!(record.reviewer_id in current_user.org_user_ids)` | 0 | 0 |
`r_open` | admitted |
| CONTROL (probe artefact) `current_user.email == 'member@example.com'`
| 0 | 0 | every row for that caller | admitted |
| CONTROL (probe artefact) `current_user.email == 'someone@else.com'` |
0 | 0 | 0 rows, `DENY (fail closed)` | 403 |
Reading of Zone 2's assumption 1:
- It holds for the type family: the runtime refuses those predicates on
every request while the lint reports 0.
- It is **falsified for the null family**. The null shapes are not
refused on the RLS data path: they compile, reach the driver, and are
answered with SQL null semantics. Within one policy, the `using` read
and the `check` evaluator disagree on `!(record.status in ['open',
null])`. The shared faces refuse the same comparands wherever those
faces run: the engine seam on every caller-supplied filter, and
`assertReadScopeComparandsRunnable` in analytics (read from code,
`service-analytics/src/read-scope-sql.ts`; not re-measured here). The
data-path gap is in the Acceptance notes and the report, not addressed
here.
- `f != current_user` was already moved by #20189 and stays reported by
the shape branch.
## The discriminator (Zone 2, item 2)
**Type family: a one-reference type swap must make the same compile
lower** (`attributeTypeFault`). When the reference pass's probe compile
is refused `unsupported`, the rule rebinds ONE reference to a value of
the other runtime type and compiles again. The candidates are each
kernel key (scalar to list, or list to scalar) and the bare root (as a
list that still carries the keys, then as one string). If that single
swap makes the predicate lower, and the swapped value lands in a lowered
field position, the refusal came from that reference's type. The type is
not the probe's to choose: `ExecutionContextSchema` declares it for the
kernel keys (read via `kernelKeyProbe`), and the root is always the
whole context object. So the runtime refuses the same position on every
request.
A refusal no single swap cures reports nothing. That is the
probe-artefact class. A constant comparison with no field, such as
`current_user.email == 'ops@acme.com'`, folds on the value: it lowers to
"no restriction" for the caller it names and is refused for everyone
else. The swap value begins with the probe string and never equals it,
so ordering folds and equality folds stay refused under every swap. The
pinned control asserts `compileCelToFilter` really answers `{ ok: true,
filter: {} }` for the named caller and `unsupported` for another, then
asserts the lint stays silent on both clauses, alone and inside `||
record.owner_id == current_user.id`.
**Null family: the verdict of the same two faces analytics runs**
(`sharedFaceRefusal`): `assertListComparandShapes` +
`normalizeFilterComparandTypes` from `@objectstack/spec/data`, run on
the probe-compiled filter and graded by the refusal's `code`
(`INVALID_FILTER`), never its prose. It is not a probe artefact, because
the probe binds only strings and string lists, which both faces accept
by construction; a probe carrying a nested record is not judged. The
face-probe control `record.assigned_to_id in current_user.org_user_ids`
stays clean.
Conservative by construction, and recorded in the docblock: a predicate
with two type faults (no single swap cures it) stays silent.
## New author-facing text (quoted verbatim; `KEY` stands where the
shipped string spells an angle-bracketed placeholder)
Type-family message: ``RLS CLAUSE `SOURCE` passes the shape check, but
the compiler refuses it for the value `VARIABLE` holds on every request
(COMPILER_DETAIL). HOLDING_SENTENCE `` + the existing clause consequence
with a new preamble:
> so `RLSCompiler` DROPS the policy on EVERY request (a request that
resolves no value drops it too). The shape check passes, so the
"uncompilable predicate" WARN is never logged; the only signal is a
per-request "DENY (fail closed)" WARN, emitted only when nothing else
applicable compiles, and nothing reports it at authoring time.
Holding sentences:
> `current_user.org_user_ids` is a membership set: `ExecutionContext`
declares it, and the kernel resolves it, as a LIST on every request, so
no request can ever put one value in this position.
> `current_user.id` holds ONE value: `ExecutionContext` declares it, and
the kernel resolves it, as a scalar on every request, so no request can
ever put a list in this position.
> `current_user` alone is the whole caller context object on every
request, never one value and never one set.
Hint leads, one per holding:
> `==` / `!=` compare ONE value and a string method takes ONE string; a
membership set is tested with `in` — "one of these" is `x in set`, "none
of these" is `!(x in set)`.
> `in` tests membership in a SET; one value is compared with `==` /
`!=`.
> `current_user` is the caller context, not a value: name the key that
holds what you mean.
Each hint lead is followed by `In this predicate: ` and the rewrites,
for example:
- ``replace `record.reviewer_id != current_user.org_user_ids` with
`!(record.reviewer_id in current_user.org_user_ids)` ``
- ``replace `record.reviewer_id == current_user.org_user_ids` with
`record.reviewer_id in current_user.org_user_ids` ``
- ``replace `record.reviewer_id in current_user.id` with
`record.reviewer_id == current_user.id` (and `!(record.reviewer_id in
current_user.id)` with `record.reviewer_id != current_user.id`); for a
set, name a membership key: `record.reviewer_id in
current_user.accessible_org_ids` / … `org_user_ids` / … `positions` ``
- ``replace `record.reviewer_id in current_user` with the key that holds
the set: `record.reviewer_id in current_user.accessible_org_ids` / …
`org_user_ids` / … `positions`, or an app-staged §7.3.1 set
`record.reviewer_id in current_user.KEY` ``
- ``replace `record.reviewer_id.startsWith(current_user)` with the key
that holds the string:
`record.reviewer_id.startsWith(current_user.email)` / …
`(current_user.id)` / … `(current_user.organization_id)` ``
The key lists are derived from the declared types (`kernelKeysHolding`)
and are not transcribed.
Null-family message: ``RLS CLAUSE `SOURCE` lowers, but to a comparand
the platform's shared filter check refuses (FACE_FIRST_SENTENCE). `` +
this consequence:
> The RLS layer does not pass its own filter through that check, so the
policy is NOT dropped: the refused comparand reaches the backend, which
answers it with semantics the platform has ruled undefined — backends
disagree, and on the SQL drivers a `null` member or bound matches no
row, so a negated list holding `null` or an ordering against `null` can
admit NOTHING. One policy can even disagree with itself: a `using` read
on a SQL driver hides rows that its `check` evaluator admits on write.
Every analytics query over the object is refused outright, because its
read-scope compiler runs the same check.
Null-family hint:
> `null` has no place inside a list or opposite an ordering operator —
the platform refuses both in every filter it is sent, because no two
backends agree on what they match. Test for no value with `== null` and
for a value with `!= null`: replace `record.status in ["open", null]`
with `(record.status in ["open"] || record.status == null)` to keep
matching a missing value, or drop the member: `record.status in
["open"]`.
Its other rewrites are ``replace `record.status in [null]` with
`record.status == null` `` and ``replace `record.status > null` with
`record.status != null` (has a value) or `record.status == null` (has
none), or compare against a real bound``. Every rewrite is a local
replacement, so it stays correct under any enclosing `!`, `&&` or `||`.
If the face refuses something the locator cannot place, the hint falls
back to the existing lowerable-subset sentence.
The existing `consequence(clause)` text for uncompilable predicates is
byte-identical; it only became parameterised.
## Pins
- **Flipped:** `accepts every kernel-resolved key in BOTH positions`
asserted `[]` for `owner_id == current_user.KEY` and `owner_id in
current_user.KEY` for every kernel key, which wrote the blind spot down
as a pin. It is now `never reports a kernel-resolved key as UNKNOWN — in
its own position it is clean, in the other it is a type fault`. It
asserts, per key and by its runtime type (the table hoisted to
`KERNEL_KEY_RUNTIME_TYPE`), `[]` in the key's own position and exactly
`[rls-predicate-unenforceable]` in the other, and never
`rls-predicate-unknown-user-variable`.
- **Pin sweep, repo-wide:** outside `packages/lint`, no test asserts an
`rls-predicate-*` id. The one hit is prose in
`packages/qa/dogfood/test/authz-conformance.matrix.ts`. The shape-branch
pins (literal list, bare root, `size()`, SQL `AND`, over-budget), the
verdict-parity corpus and the disjointness test are unchanged and green.
A genuinely illegal shape keeps its refusal verbatim.
- **New block** `the refusals the SHAPE check cannot see are reported
(#19951)`, table-driven over both clauses:
- 8 type-fault rows and 7 null rows; each asserts
`isSupportedRlsExpression(source) === true` (why it was silent), then
exactly `[[rls-predicate-unenforceable,
permissions[0].rowLevelSecurity[0].CLAUSE]]`, and that the hint contains
the pasteable rewrite.
- Message substance for each family: a DROP with `RLS_DENY_FILTER` on
`using` and `PermissionDeniedError` on `check`, versus "NOT dropped".
- Every site rewritten in one finding.
- Controls: the literal list keeps one finding, and every spelling the
hints point at is clean on both clauses.
- The probe-artefact control.
- Reach through `runAuthoringRules('validate', …)`.
## Verification (at `77060e6d`, the merge of `origin/main` `0bd11261`)
- `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2
--reporter=verbose src/validate-rls-predicate-enforceability.test.ts`:
`Tests 120 passed (120)`.
- `pnpm --filter @objectstack/lint test`: `Test Files 108 passed (108)`,
`Tests 4204 passed (4204)`. This is the whole package, the rule's only
home.
- `pnpm --filter @objectstack/lint typecheck`: exit 0. `tsc --noEmit`,
then `check:test-typecheck` `OK` against `tsconfig.test.json`.
`--listFiles` includes the test file, and it carries 0 type errors; the
6 ledgered test-layer errors live in two other files.
- `pnpm --filter @objectstack/lint build`: exit 0.
- **Ablations**, one-time and never left in the tree. Each went through
`node scripts/ablation-replace.mjs` in WRAP mode, with the anchor hit
once (1 to 0), a new blob, and the restore proven (blob == HEAD
`8589ec8c`, `git diff HEAD` empty). The subject is imported from `src`,
so no `dist` step applies.
- **A.** `attributeTypeFault(...)` replaced by `?? FAKE_FAULT` (every
refusal reported). Result: 2 failed / 118. Red: the probe-artefact
control, and `§7.3.1 … a key used in BOTH positions takes the membership
answer`. That is the discriminator is load-bearing.
- **B.** `sharedFaceRefusal` made to return `null`. Result: 8 failed /
112, the 7 null rows plus "NOT reported as a drop".
- **C.** The swap may never land (`sites.length > 0` becomes `< 0`).
Result: 12 failed / 108: the 8 type rows, the flipped kernel-key pin,
the message test, the every-site test, and the `os validate` reach.
- **Census (Zone 2, item 4):** the BASE rule (`git show 560b724:…`,
blob `cb850a12`, as a throwaway module) and this PR's rule were run in
one process over every `using` / `check` literal. Outside tests: 126
predicates, 77 of them `current_user` (the note's control of 77
reproduced), **0 changed**. Including every test fixture: 327
predicates, 205 `current_user`, **0 changed**. The 4 template-literal
predicates the extraction skipped (`${ownerField} == current_user.id`
and similar) and the one JSON fixture are all type-correct. No in-repo
producer is newly flagged.
- **Interplay (Zone 2, item 5):** the null-family check calls the shared
face `assertListComparandShapes`
(`packages/spec/src/data/filter-comparand-shape.ts`) as it stands on
`main`. #19886 stage 2b edits that file's `$ne` / array arm. The lint
only ever hands it filters the compiler already accepted, and the
compiler refuses `==` / `!=` against a list, so a `$ne` array never
reaches the face from here. Measured on `main`; not waited on.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`, re-derived at `77060e6d` (59 commands, a
superset of the lead's 52 once the changeset exists), all run.
Reconciled with `--ran`: `59 derived, 57 run, 2 NOT-MEASURED, 0 UNRUN`;
all 57 exited 0. NOT MEASURED: `pnpm check:dual-build-cjs-loads` and
`pnpm check:type-check-debt`. Both exit 3, PREREQUISITE NOT MET: they
need every package's `dist/` (a whole-repo build), which is CI's.
- **Consumer suites** (`cli`, `mcp`, `metadata-protocol`,
`platform-objects`, `cloud-connection`): not run locally and declared to
CI. The public surface is byte-identical: no export, no wire shape. The
census above measures every in-repo predicate, fixtures included, at 0
flips, and no suite outside `packages/lint` asserts an `rls-predicate-*`
id.
- **Dogfood:** the showcase predicates (`owner == current_user.email`
and similar) are inside the 0-flip census.
## Choices settled here (four axes)
1. **Report the null family although the data path does not refuse it.**
- **Business need:** measured. `!(record.status in ['open', null])`
hides every `closed` row on read while its own `check` admits the write,
and an AI writing a blocklist "neither closed nor missing" lands exactly
here.
- **Long-term soundness:** the contract (the null rulings) already
refuses these comparands at every face that judges them. The lint agrees
with the contract rather than with the one path that skips it.
- **Guarding against AI metadata mistakes:** a loud authoring refusal,
with a rewrite, instead of backend-dependent silence.
- **Startup scope:** no new capability, no runtime change.
- Not reporting it would leave "validated clean, behaves per backend" in
place.
2. **Same id (`rls-predicate-unenforceable`), not a new one.** Zone 1's
direction names the id for the compile-refusal family, and the null
family's fix is the same kind of edit (rewrite inside the enforceable
subset). An allowlist keyed on the id already means "this predicate
enforces nothing", and a new public id would widen `@objectstack/lint`'s
exported surface for no new decision. The message says which class it
is.
3. **The discriminator** as above: asked of the compiler, the way
`userVariableIsScalarPositioned` already asks it, and never by grading
prose.
## Acceptance notes
- **Runtime gap (reported, not addressed here):** the RLS data path does
not run the shared comparand faces on its own compiled filter. A `null`
list member or `null` ordering bound in a policy reaches the driver and
the `check` evaluator, which disagree.
- Measured through the real plugin on driver-sql: `!(record.status in
['open', null])` as `using` reads 0 rows (the `closed` row is hidden).
As `check`, it admits a `closed` insert and refuses an `open` one.
- `record.status in ['open', null]` reads `r_open` only.
- `record.status > null` reads 0 rows and refuses both inserts.
- The contract's own text calls these cells "constructively unreachable
through the compile face" (`driver-memory/src/memory-matcher.ts`), and
they are reachable through RLS.
- Handed to the PM seat for the filing gate; this PR changes no runtime
source.
-
`packages/spec/src/migrations/entries/semantic/18.cel-predicate-list-comparand-refused.ts`
says "The authoring lint reports a list literal as
rls-predicate-unenforceable; a membership set holds its value only per
request, so that form is refused at request time." That is still true,
but no longer complete: the lint now reports the membership-set form
too. Carrier: none.
- An ordering against a membership set (`record.f >
current_user.positions`) lowers to `$gt` over a list. Neither the
compiler nor the faces refuse it, and its runtime answer was not
exercised here. Carrier: none.
- Conservative limits, by design: a predicate with two type faults stays
silent. So does an unknown app key in a scalar position when a later
type fault masks it; that is `userVariableIsScalarPositioned`'s existing
behaviour.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01Rjy9MeetSfq34PKn81CRiN)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 84156c7 commit af32cf9
3 files changed
Lines changed: 587 additions & 42 deletions
File tree
- .changeset
- packages/lint/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
698 | 698 | | |
699 | 699 | | |
700 | 700 | | |
| 701 | + | |
| 702 | + | |
| 703 | + | |
| 704 | + | |
| 705 | + | |
| 706 | + | |
| 707 | + | |
| 708 | + | |
| 709 | + | |
| 710 | + | |
| 711 | + | |
| 712 | + | |
| 713 | + | |
| 714 | + | |
| 715 | + | |
| 716 | + | |
| 717 | + | |
| 718 | + | |
701 | 719 | | |
702 | 720 | | |
703 | 721 | | |
| |||
713 | 731 | | |
714 | 732 | | |
715 | 733 | | |
716 | | - | |
| 734 | + | |
717 | 735 | | |
718 | 736 | | |
719 | | - | |
720 | | - | |
721 | | - | |
722 | | - | |
| 737 | + | |
| 738 | + | |
| 739 | + | |
| 740 | + | |
| 741 | + | |
| 742 | + | |
| 743 | + | |
| 744 | + | |
723 | 745 | | |
724 | 746 | | |
725 | | - | |
| 747 | + | |
| 748 | + | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
726 | 752 | | |
727 | 753 | | |
728 | 754 | | |
| |||
742 | 768 | | |
743 | 769 | | |
744 | 770 | | |
745 | | - | |
746 | | - | |
747 | | - | |
748 | | - | |
749 | | - | |
750 | | - | |
751 | | - | |
752 | | - | |
753 | | - | |
| 771 | + | |
| 772 | + | |
754 | 773 | | |
755 | | - | |
756 | | - | |
757 | | - | |
758 | | - | |
759 | | - | |
760 | | - | |
761 | | - | |
762 | | - | |
| 774 | + | |
763 | 775 | | |
764 | 776 | | |
765 | 777 | | |
| |||
814 | 826 | | |
815 | 827 | | |
816 | 828 | | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
| 842 | + | |
| 843 | + | |
| 844 | + | |
| 845 | + | |
| 846 | + | |
| 847 | + | |
| 848 | + | |
| 849 | + | |
| 850 | + | |
| 851 | + | |
| 852 | + | |
| 853 | + | |
| 854 | + | |
| 855 | + | |
| 856 | + | |
| 857 | + | |
| 858 | + | |
| 859 | + | |
| 860 | + | |
| 861 | + | |
| 862 | + | |
| 863 | + | |
| 864 | + | |
| 865 | + | |
| 866 | + | |
| 867 | + | |
| 868 | + | |
| 869 | + | |
| 870 | + | |
| 871 | + | |
| 872 | + | |
| 873 | + | |
| 874 | + | |
| 875 | + | |
| 876 | + | |
| 877 | + | |
| 878 | + | |
| 879 | + | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
| 897 | + | |
| 898 | + | |
| 899 | + | |
| 900 | + | |
| 901 | + | |
| 902 | + | |
| 903 | + | |
| 904 | + | |
| 905 | + | |
| 906 | + | |
| 907 | + | |
| 908 | + | |
| 909 | + | |
| 910 | + | |
| 911 | + | |
| 912 | + | |
| 913 | + | |
| 914 | + | |
| 915 | + | |
| 916 | + | |
| 917 | + | |
| 918 | + | |
| 919 | + | |
| 920 | + | |
| 921 | + | |
| 922 | + | |
| 923 | + | |
| 924 | + | |
| 925 | + | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
| 938 | + | |
| 939 | + | |
| 940 | + | |
| 941 | + | |
| 942 | + | |
| 943 | + | |
| 944 | + | |
| 945 | + | |
| 946 | + | |
| 947 | + | |
| 948 | + | |
| 949 | + | |
| 950 | + | |
| 951 | + | |
| 952 | + | |
| 953 | + | |
| 954 | + | |
| 955 | + | |
| 956 | + | |
| 957 | + | |
| 958 | + | |
| 959 | + | |
| 960 | + | |
| 961 | + | |
| 962 | + | |
| 963 | + | |
| 964 | + | |
| 965 | + | |
| 966 | + | |
| 967 | + | |
| 968 | + | |
| 969 | + | |
817 | 970 | | |
818 | 971 | | |
819 | 972 | | |
| |||
0 commit comments