Repository navigation
Commit 6dd2ca5
fix(plugin-security, plugin-auth)!: a person-to-permission-set assignment refuses an agent ceiling set at every write door, and so does the self-registration permission set (#30578)
<!-- claude-projects-attribution: {"github_login":"os-sales"} -->
_Requested by **Sales** · [project
thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuLBXff21cMaYo16jV4jZerR)_
Clause-②: no (narrowing) — a non-system insert or update of
`sys_user_permission_set` that would store an assignment naming one of
the three MCP agent ceiling sets (`mcp_agent_data_read`,
`mcp_agent_data_write`, `mcp_agent_restricted`) is refused with `400
VALIDATION_FAILED` (`invalid_value` at `permission_set`), for every
caller, a platform administrator included; and an audience configuration
whose `selfRegistrationPermissionSet` names one is refused (`[audience]
invalid audience configuration`), at construction and through the auth
settings; nothing is widened.
Metadata-protocol: none
Skills-docs: none — `skills/**` teaches neither the assignment door's
sets nor the self-registration set. The `selfRegistrationPermissionSet`
description in `packages/spec/src/system/auth-config.zod.ts` names the
`admin_full_access` refusal, which stays true; it does not list every
set the runtime refuses, and this change makes no sentence there false.
## 维护者速读
- **改了什么**:人员权限集分配表 `sys_user_permission_set`
的每个写入口(单条新建、更新、`createMany`、`updateMany`、两种批量、两种导入)现在拒绝存入三个 MCP
智能体上限权限集,统一返回 `400 VALIDATION_FAILED`,字段 `permission_set`、代码
`invalid_value`;受众配置的自助注册权限集 `selfRegistrationPermissionSet`
也拒绝这三个集(启动时与认证设置里同样拒绝)。
- **为什么改**:这三个权限集是经 MCP 接入的 AI
助手自己的权限上限,由其用户同意的范围在请求时推导,从不读分配行;分配表的每一行都是给人的授权,所以规则在服务端对每个写入口、每个调用者(平台管理员也不例外)成立;自助注册者也是人,所以给他们的权限集同样不能是智能体上限。
-
**风险与回滚**:破坏性收窄,只影响曾经通过分配表给人分配这三个集、或把其中之一配置为自助注册权限集的部署:新写入和对此类旧行的编辑被拒,旧行原样保留、可删除或改名;这样的受众配置在启动时被拒,改成给人的权限集即可;系统写入不受本钩子评判;回滚即
revert 本 PR(一个新钩子模块、一行注册、受众校验里一条拒绝)。
- **先看**:①
`packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts` 的
`onUpdate`(以写后行判定,覆盖按谓词的多行更新)与它的系统写入说明;②
`packages/plugins/plugin-auth/src/audience-posture.ts` 中
`admin_full_access` 拒绝旁新增的智能体上限拒绝;③
`content/docs/permissions/system-context.mdx` 的 23f 行。
- **两分钟验证**:`pnpm --filter @objectstack/plugin-security exec vitest run
src/agent-ceiling-grant-refusal.test.ts`,预期 `Tests 14 passed (14)`。
- **需要你做的**:无,席位落地。
## What changes
- **The rule (ADR-0090 D10).** The MCP agent ceiling sets
(`MCP_AGENT_PERMISSION_SETS`, imported from `@objectstack/spec/ai`,
never retyped) are an AI agent's own grants: the door that admits an
agent connected over MCP derives the ceiling from the scopes its user
consented to and puts it on the agent's request. No assignment row is
read for it, and no `sys_user` row is an agent principal, so every
`sys_user_permission_set` row is a person's grant and never names one.
- **Where it holds.** A new module,
`packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.ts`,
registers a `beforeInsert` and a `beforeUpdate` engine hook on
`sys_user_permission_set` (priority 35: after the catalog check that the
name is a set at all, before the holder's membership is read). Every
door that writes the table writes through the engine, so each one is
covered: the data API's create and update, `createMany`, `updateMany`,
the per-object and cross-object batch, and the sync and job import
doors.
- **Which rows are judged.** An insert judges the row's set. An update
judges the set the row holds after the write: the payload's
`permission_set` when it carries one, the stored one when it does not.
So an update that keeps an assignment naming a ceiling in force (a new
holder, a validity window, an echo of the set) is refused as well as a
rename to a ceiling, by id and by predicate.
- **The self-registration set (`@objectstack/plugin-auth`).** A
self-registrant is a person, so `assertAudienceConfig` refuses an agent
ceiling set as `selfRegistrationPermissionSet`, beside its refusal of
`admin_full_access`, with the message that refusal uses (`[audience]
invalid audience configuration: selfRegistrationPermissionSet must not
be '...'`). It applies at construction (`auth.audience` in the stack
config stops the boot) and on the auth settings patch
(`audience_self_registration_permission_set` is refused and the standing
audience keeps ruling). `AudienceConfigSchema` in `@objectstack/spec` is
not touched by this PR.
- **What it leaves alone.** A delete is never judged. A system write
(`isSystem`) is not judged. Writer by writer: the platform-admin
promotion and the organization-admin reconcile name their sets in code,
and neither is a ceiling; the self-registration grant writes only the
validated `selfRegistrationPermissionSet`; seed replay and the
development-only demo-staff seed write the sets their stack declares,
unjudged. Every other permission set stays assignable. An agent
principal's ceiling is resolved exactly as before. An organization
administrator is still refused the same sets on the organization
boundary first (`403 PERMISSION_DENIED`).
- **The envelope.** `validationFailure` (the constructor both HTTP doors
map) with one `fields[]` entry: `field: 'permission_set'`, `code:
'invalid_value'` (the ADR-0114 member: the set exists, so it is not
`reference_not_found`), `value`, and a sentence naming the way through.
No new error code (ADR-0112).
- **Registration.** `security-plugin-start-hooks.ts` calls
`registerAgentCeilingGrantRefusal(ql, ctx.logger)` beside the
permission-set name hooks. It is idempotent on a re-run of `start()`,
and warns and returns `false` on an engine with no hook registry.
- **Public surface.** Unchanged: none of the new module's symbols is
re-exported from the package entry (measured on the built
`dist/index.d.ts` and `dist/index.d.mts`: 0 hits for each of the five
names, positive control `SecurityPlugin` 24 hits each).
- **Changeset.** `.changeset/sec-hardening-r6-agent-ceiling-grant.md`,
`patch` on `@objectstack/plugin-security` and `@objectstack/plugin-auth`
with a BREAKING banner, both declaration lines, its ADR-0087
disposition, and the fix for a deployment that holds such an assignment
(delete it, or rename it to a set meant for people) or names such a set
for self-registrants (declare a set meant for people).
- **System-context register.**
`content/docs/permissions/system-context.mdx` gains row 23f. Its Get
cell names, writer by writer, what keeps a ceiling out of a system write
(the fixed-name writers by construction, the self-registration set by
the audience validation); its Lose cell names the seeds, which write
what their stack declares. A non-system write that would store an
assignment naming an MCP agent ceiling set is refused for every caller.
Its generated site register now names
`agent-ceiling-grant-refusal.ts#onInsert` and `#onUpdate` under that row
(regenerated with `node scripts/check-system-context-census.mjs
--write`).
## Tests
-
**`packages/plugins/plugin-security/src/agent-ceiling-grant-refusal.test.ts`**
(new, 14 tests; real ObjectQL engine, SQL driver on in-memory SQLite,
the real `SecurityPlugin` started on it):
- each of the three ceiling sets is refused on insert by a platform
administrator (`400`, `VALIDATION_FAILED`, one field entry
`permission_set` / `invalid_value` / the value), and nothing is stored;
- a batch insert with one ceiling row among person grants is refused
whole;
- an update by id renaming a grant to a ceiling, and a predicate update
renaming several, are refused and leave every row untouched;
- an update that keeps a stored ceiling assignment in force (new holder,
new window, echo of the set, by predicate) is refused;
- controls: a person's set is assignable; renaming a stored ceiling
assignment to a person's set, and deleting one, both land; system
inserts and a system update stand down; an agent principal still
resolves its ceiling with no assignment row while a person resolves
none;
- registration is idempotent, and an engine with no hook registry gets
`false` and one warning.
- **`packages/plugins/plugin-auth/src/audience-posture.test.ts`**
(beside the `admin_full_access` pin): each of the three ceiling sets is
refused as `selfRegistrationPermissionSet` under `open` and
`email_domain`, with the message pinned; control: `member_default` and
`portal_user` stay declarable. **`audience-posture-setting.test.ts`**: a
ceiling set stored as `audience_self_registration_permission_set` is
refused through the settings channel and the standing `invite_only`
keeps ruling.
-
**`packages/qa/dogfood/test/grant-table-write-contract.dogfood.test.ts`**
(new top-level block, 5 tests on the booted showcase stack over HTTP,
sharing the file's one showcase boot with the existing block; its
`beforeAll` asserts the shipped catalog serves all three ceiling sets,
so the catalog check does not answer first):
- `POST` of each ceiling set, and a `PATCH` renaming a person grant to
one, answer the full envelope and store nothing;
- `createMany` and the cross-object `/batch` answer the full envelope;
- `updateMany` and the per-object batch fail the row in their per-row
report with `code: 'VALIDATION_FAILED'`, `httpStatus: 400`;
- the sync import and the import job fail the row with `code:
'invalid_value'` at `permission_set`.
- **Ablation, plugin-auth.** With the new refusal's condition replaced
by `false` (via `scripts/ablation-replace.mjs`, restored automatically):
4 failed / 75 passed across the two files (the three ceiling pins and
the settings-channel pin red, the person-set control and every other pin
green); restored, the file's blob equals the committed one and both
files pass.
- **Ablation, plugin-security.** With the guard line replaced by a no-op
(via `scripts/ablation-replace.mjs`, restored automatically): the unit
file goes 7 failed / 7 passed (every refusal pin red, every control
green), and the dogfood block, rebuilt and checked with
`scripts/ablation-dist-preflight.mjs`, goes 5 failed / 3 passed (the 3
are the file's existing grant-table pins), and each of the four door
pins after the first, run alone so no earlier row feeds a later one,
also goes red. Restored and rebuilt, both are green again.
## Acceptance notes
- **The per-row report doors carry no `fields[]`.** `updateMany` and the
per-object batch answer `200` with a per-row report whose failed row
carries the refusal's `code` and `httpStatus` but not its `fields[]`
entry; that is those doors' existing report shape for every validation
refusal, not something this change introduces, so it is pinned as it
stands.
- **The dogfood file boots the showcase once.** Both blocks share one
file-level boot and sign up their own users. Measured on a shared agent
box: 13.31 s and 13.52 s with two boots; 10.05, 9.64, 9.78, 9.21 and
9.00 s with one. That is at the 10 s line, so it carries no
`test-budget:` header; a CI reading over it would be the case for one.
- **Stored assignments are not migrated.** An assignment stored before
this release that names a ceiling set is left in place and can no longer
be edited; the changeset gives the query to list them and the two ways
to clear one.
- **Clause-② spelling.** The diff narrows the accept set and widens
nothing (no new export reaches the package entry), so the declaration is
`no (narrowing)`.
## Verification
Head: `e1305f7524` (branch `claude/sec-hardening-r6`, merged with `main`
at 6701c08). The plugin-security ablation readings were taken on
`1b9179369a`, and that module's code is unchanged since (its note was
reworded). The plugin-auth ablation ran on `398fe7f1d6` (the one commit
after it changes only the dogfood test file). Both package suites, the
battery and the gates ran at `e1305f7524`.
<details><summary>plugin-auth pins: 79 passed; ablated: 4 failed, 75
passed</summary>
```
pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/audience-posture.test.ts src/audience-posture-setting.test.ts
(the new refusal's condition replaced by `false` through scripts/ablation-replace.mjs, restored on exit; on commit 398fe7f)
Test Files 2 failed (2)
Tests 4 failed | 75 passed (79)
red: the three ceiling names under open and email_domain, and the settings-channel pin
green: the person-set control, the admin_full_access pin, every other pin
restore: whole-tree git status --porcelain empty; the file's blob equals the committed blob (3f5e481)
Test Files 2 passed (2)
Tests 79 passed (79)
```
</details>
<details><summary>plugin-security unit file: 14 passed; ablated: 7
failed, 7 passed</summary>
```
pnpm --filter @objectstack/plugin-security exec vitest run src/agent-ceiling-grant-refusal.test.ts
(guard line replaced by a no-op through scripts/ablation-replace.mjs, restored on exit; head 1b91793)
Test Files 1 failed (1)
Tests 7 failed | 7 passed (14)
red: the three single inserts, the batch insert, the rename by id, the predicate rename, the update that keeps a stored one in force
green: the spec list, the person-set control, the rename-away and delete control, the system stand-down, the agent principal, idempotent registration, no hook registry
restore: whole-tree git status --porcelain empty; sha256 of the module matches the pre-ablation hash
```
</details>
<details><summary>Dogfood file: 8 passed on one boot; ablated through
the built bundle: 5 failed, 3 passed</summary>
```
head 1b91793 (two boots then)
ablated: pnpm --filter @objectstack/plugin-security build exit 0
node scripts/ablation-dist-preflight.mjs @objectstack/plugin-security 'throw agentCeilingGrantError(name)' --absent
marker absent from all 6 built files exit 0
pnpm --filter @objectstack/dogfood exec vitest run test/grant-table-write-contract.dogfood.test.ts
Tests 5 failed | 3 passed (8) exit 1
each of PATCH, createMany+/batch, updateMany+per-object batch, both imports run alone with -t:
Tests 1 failed | 7 skipped (8) exit 1 each
restored: whole-tree git status --porcelain empty; rebuilt; marker present in 2 built files; 8 passed (8)
one boot (final file): 8 passed (8) on each of five runs; file line 10052, 9640, 9778, 9209, 9003 ms
```
</details>
<details><summary>Package suites: plugin-auth 4018 passed,
plugin-security 5407 passed; typecheck clean</summary>
```
pnpm --filter @objectstack/plugin-auth test exit 0
Test Files 224 passed (224)
Tests 4018 passed | 10 skipped (4028)
pnpm --filter @objectstack/plugin-security test exit 0
Test Files 246 passed (246)
Tests 5407 passed | 45 skipped (5452)
pnpm --filter @objectstack/dogfood --filter @objectstack/plugin-auth --filter @objectstack/plugin-security typecheck exit 0
```
</details>
<details><summary>Full dogfood battery at e1305f7: 420 files passed,
3505 tests passed</summary>
```
pnpm --filter @objectstack/dogfood test (no OS_TEST_SHARD, full closure built first, under the verify lock) exit 0
Test Files 420 passed | 3 skipped (423)
Tests 3505 passed | 36 skipped (3541)
```
</details>
<details><summary>Derived gates at e1305f7: 93 derived, 93 run, 0 not
measured; all exit 0</summary>
`node scripts/pm/dispatch-gates.mjs --commands` derived 93 commands for
this change set (nine paths); `--ran` on the record: 93 run, 0
NOT-MEASURED, 0 UNRUN. Each exit code was captured before any pipe.
| exit | command |
|---|---|
| 0 | `node scripts/check-adr-0087-registration.mjs --base origin/main`
|
| 0 | `node scripts/check-adr-0087-registration.mjs --self-test` |
| 0 | `node scripts/check-adr-links.mjs` |
| 0 | `node scripts/check-adr-links.mjs --self-test` |
| 0 | `node scripts/check-changeset-no-major.mjs --base origin/main` |
| 0 | `node scripts/check-closing-keyword-parity.mjs` |
| 0 | `node scripts/check-closing-keyword-parity.mjs --self-test` |
| 0 | `node scripts/check-comment-mask-adoption.mjs` |
| 0 | `node scripts/check-comment-mask-adoption.mjs --self-test` |
| 0 | `node scripts/check-comment-mask-corpus.mjs` |
| 0 | `node scripts/check-dev-prereqs.mjs --self-test` |
| 0 | `node scripts/check-doc-route-spelling.mjs --advisory` |
| 0 | `node scripts/check-doc-route-spelling.mjs --self-test` |
| 0 | `node scripts/check-docs-section-name.mjs` |
| 0 | `node scripts/check-docs-section-name.mjs --self-test` |
| 0 | `node scripts/check-dts-emitted.mjs --self-test` |
| 0 | `node scripts/check-empty-changeset.mjs --base origin/main` |
| 0 | `node scripts/check-empty-changeset.mjs --self-test` |
| 0 | `node scripts/check-issue-citations.mjs` |
| 0 | `node scripts/check-plugin-teardown-shape.mjs` |
| 0 | `node scripts/check-plugin-teardown-shape.mjs --self-test` |
| 0 | `node scripts/check-section-landing-index.mjs` |
| 0 | `node scripts/check-section-landing-index.mjs --self-test` |
| 0 | `node scripts/check-tenant-audit-census.mjs` |
| 0 | `node scripts/check-tenant-audit-census.mjs --self-test` |
| 0 | `node scripts/ci/assert-turbo-remote-cache.mjs --self-test` |
| 0 | `node scripts/ci/plan-test-core-shards.mjs --self-test` |
| 0 | `node scripts/ci/prune-turbo-cache.mjs --self-test` |
| 0 | `node scripts/docs-audit/check-affected-docs.mjs` |
| 0 | `node scripts/docs-audit/check-drift-comment.mjs` |
| 0 | `node scripts/pm/release-rehearsal-clone.mjs --self-test` |
| 0 | `node scripts/release-pending-publish.mjs --self-test` |
| 0 | `pnpm --filter @objectstack/lint run
check:doc-formula-expressions` |
| 0 | `pnpm --filter @objectstack/lint run check:doc-security-posture` |
| 0 | `pnpm --filter @objectstack/spec run check:docs` |
| 0 | `pnpm --filter @objectstack/spec run check:duration-unit-keys` |
| 0 | `pnpm --filter @objectstack/spec run check:empty-state` |
| 0 | `pnpm --filter @objectstack/spec run check:liveness` |
| 0 | `pnpm --filter @objectstack/spec run check:skill-examples` |
| 0 | `pnpm --filter @objectstack/spec run check:strictness-ledger` |
| 0 | `pnpm --filter @objectstack/spec run check:variant-docs` |
| 0 | `pnpm --filter @objectstack/spec run check:yaml-examples` |
| 0 | `pnpm check:changeset-gate-self-tests` |
| 0 | `pnpm check:ci-filter-parity` |
| 0 | `pnpm check:closed-ceremony-text` |
| 0 | `pnpm check:corpus-claim-drift` |
| 0 | `pnpm check:cross-package-test-inputs` |
| 0 | `pnpm check:dispatcher-error-vocabulary` |
| 0 | `pnpm check:doc-anchors` |
| 0 | `pnpm check:doc-authoring` |
| 0 | `pnpm check:doc-frontmatter` |
| 0 | `pnpm check:docs-audit-scope` |
| 0 | `pnpm check:docs-redirects` |
| 0 | `pnpm check:docs-single-h1` |
| 0 | `pnpm check:docs-spec-enumerations` |
| 0 | `pnpm check:docs-transcript-drift` |
| 0 | `pnpm check:driver-memory-census` |
| 0 | `pnpm check:dts-closure` |
| 0 | `pnpm check:dual-build-cjs-loads` |
| 0 | `pnpm check:engine-double-contract` |
| 0 | `pnpm check:gitlink-declared` |
| 0 | `pnpm check:i18n` |
| 0 | `pnpm check:i18n-stale-fill` |
| 0 | `pnpm check:issue-citations` |
| 0 | `pnpm check:keyed-text-bounds` |
| 0 | `pnpm check:lean-entry-closure` |
| 0 | `pnpm check:logger-receiver-detach` |
| 0 | `pnpm check:merge-driver` |
| 0 | `pnpm check:nul-bytes` |
| 0 | `pnpm check:objectql-double-limit` |
| 0 | `pnpm check:org-identifier` |
| 0 | `pnpm check:page-declaration-shape` |
| 0 | `pnpm check:platform-object-tenancy-census` |
| 0 | `pnpm check:published-files` |
| 0 | `pnpm check:published-readme-links` |
| 0 | `pnpm check:query-options-erasure` |
| 0 | `pnpm check:react-page-adapter-contract` |
| 0 | `pnpm check:refd-timer-probe` |
| 0 | `pnpm check:registry-log-declared` |
| 0 | `pnpm check:rest-log-spy-declared` |
| 0 | `pnpm check:role-word` |
| 0 | `pnpm check:skill-identifier-liveness` |
| 0 | `pnpm check:slot-lookup` |
| 0 | `pnpm check:sourcemap-no-sources-content` |
| 0 | `pnpm check:system-context-census` |
| 0 | `pnpm check:test-source-alias` |
| 0 | `pnpm check:tier-file-adoption` |
| 0 | `pnpm check:type-check-coverage` |
| 0 | `pnpm check:type-check-debt` |
| 0 | `pnpm check:undeclared-dep-imports` |
| 0 | `pnpm check:vendor-version-stamps` |
| 0 | `pnpm check:watch-hint-literal` |
| 0 | `pnpm check:where-matcher` |
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg)_
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01L6bhgVfjk2i8T2D4RmxsKg
<!-- signed: security#1 · os-sales · session_01L6bhgVfjk2i8T2D4RmxsKg
-->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent b3d2f4b commit 6dd2ca5
9 files changed
Lines changed: 752 additions & 13 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- plugins
- plugin-auth/src
- plugin-security/src
- qa/dogfood/test
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
139 | 139 | | |
140 | 140 | | |
141 | 141 | | |
| 142 | + | |
142 | 143 | | |
143 | 144 | | |
144 | 145 | | |
| |||
578 | 579 | | |
579 | 580 | | |
580 | 581 | | |
| 582 | + | |
| 583 | + | |
581 | 584 | | |
582 | 585 | | |
583 | 586 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| 39 | + | |
39 | 40 | | |
40 | 41 | | |
41 | 42 | | |
| |||
232 | 233 | | |
233 | 234 | | |
234 | 235 | | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
235 | 249 | | |
236 | 250 | | |
237 | 251 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
22 | 22 | | |
23 | 23 | | |
24 | 24 | | |
| 25 | + | |
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
| |||
452 | 453 | | |
453 | 454 | | |
454 | 455 | | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
455 | 474 | | |
456 | 475 | | |
457 | 476 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
93 | 94 | | |
94 | 95 | | |
95 | 96 | | |
| |||
217 | 218 | | |
218 | 219 | | |
219 | 220 | | |
220 | | - | |
221 | | - | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
222 | 226 | | |
223 | 227 | | |
224 | 228 | | |
| |||
308 | 312 | | |
309 | 313 | | |
310 | 314 | | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
311 | 324 | | |
312 | 325 | | |
313 | 326 | | |
| |||
0 commit comments