Repository navigation
Data scope via dynamic sharing rules; rescore results when a bonus is decided - #4
Conversation
…s decision Replaces the 28 statically declared per-unit criteria sharing rules with sharing rules PROVISIONED AS DATA from the plan configuration, so onboarding a customer organization needs no metadata change. Why rules and not per-record shares, measured on the runtime (dev :3200): `sys_record_share` is engine-owned (data API create -> 405), and the grant entry point refuses every non-`user` recipient with "group/position recipients are delivered via sharing rules (ADR-0111 D7)"; the read filter only ever queries `recipient_type: 'user'` rows. Unit-wide delivery is therefore a sharing-rule concern. The evaluator expands a rule into per-user `sys_record_share` rows and recomputes them when business-unit membership or the unit tree moves, which the app would otherwise have to re-implement. - src/services/sharing-service.ts: derives the rule set from participating subjects, their configured leaders and the staff assignments, then writes it idempotently. Each rule is anchored to the business unit it derives from, because recipient expansion screens units by an exact organization match. - PlanPublishHook provisions on publish; failures are logged, never blocking. - BonusAfterDecideHook rescores the four-dimension results as soon as a bonus is approved or rejected on an already-approved sheet. - Executive leader permission set narrowed from org to own scope: what a leader reaches is now decided by record sharing, not by a blanket grant. - Deletes src/security/sharing-rules.ts and the sharingRules config key. - e2e extended with non-admin data-scope cases (T38-T50); unit tests for the rule derivation. KNOWN BLOCK, filed as objectstack-ai/objectstack#14547: the unit-recipient half grants nothing against the demo SEED organization tree. A rule is always stamped with the caller's organization, seeded `sys_business_unit` rows carry `organization_id = NULL`, and `BusinessUnitGraphService.orgScope` compares the two with strict equality instead of the platform's null-inclusive tenant screen -- so expansion yields zero users, silently. Proven by a single-variable control: giving the seeded unit the organization the rule already carries turns 0 grants into 3 with nothing else changed. The user-recipient half (executive leader) works today. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…ication Three accounts, one runtime (dev :3200, fresh DB, after the e2e run): admin sees 15 entry sheets, the executive leader sees exactly the 3 sheets of the subject he oversees (user-recipient sharing rule — works), and the department reporter sees none (unit-recipient expansion blocked by objectstack-ai/objectstack#14547). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
Three things stood between the sharing rules and a working data scope. All
three are now measured green: pnpm e2e is 53/53 on a wiped DB, twice.
1. Re-assert the rules at every business trigger. The platform SKIPS record
sharing materialisation for `isSystem` writes by design (plugin-sharing
rule-hooks: "sharing materialisation skipped for isSystem writes"; the
sanctioned remedy is re-evaluating the rule, which is idempotent). Every
business record in this app -- entry sheets, check tasks, adjustments,
results -- is created by a hook under the system context, so none of them
were ever granted at creation. `ensureRule` therefore writes the rule even
when its content is unchanged, and publish / branch-check entry / adjustment
approval / result rescore each re-assert the rules for the objects they just
wrote (`provisionPlanSharing(api, plan, { objects })`).
2. Align the demo fixture's tenancy (src/data/align-demo-units.ts). Seeded
`sys_business_unit` rows carry `organization_id = NULL` while a rule is
stamped with the caller's organization, and recipient expansion compares the
two with strict equality -- so only seeded units expanded to nobody. This
stamps them the way an admin-created unit already is, at
`kernel:bootstrapped` (verified to fire on a fresh boot, with the
organization already resolvable), and is narrowed three ways: dev/test only,
only the ids this app seeds, only rows whose organization is NULL.
TEMPORARY -- delete it together with its call when
objectstack-ai/objectstack#14547 ships.
3. Restrict the reporter's row-level security to the person dimension. The old
predicate `person == current_user.id` also hid the department and branch rows
(their `person` is empty), so a reporter could not see their own unit's
result at all. Sharing stays the outer gate; the policy now only narrows the
person dimension.
e2e adds T51 (reporter sees their unit's department row and their own person
row, not another user's) and T52 (the fixture alignment), and gives the
asynchronous re-grant a bounded wait instead of a weakened assertion.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
Code review report (os-project-dev-review)Tier: full (high-risk: modifies existing hooks and permission sets) Library-specific checks: Findings: Generated by Claude Code |
…w findings
F1 (blocker) — rules are now derived PER PLAN and reconciled, so a grant can be
taken away. Criteria carry the plan (`{ plan, subject }` / `{ plan, branch }` /
`{ plan, unit }` / `{ plan, person }`) and names are namespaced `kpi_p<plan>_…`.
`kpi_check_task` and `kpi_adjustment` gain a stored `plan` lookup, written by the
hooks, because a rule condition can only compare columns of its own object.
After computing the desired set, `reconcilePlanSharing` deactivates every
kpi-managed rule of that plan that is no longer desired -- replaced leader,
dropped subject, dropped staff assignment -- and also deactivates any legacy
plan-agnostic `kpi_share_*` rule, which is the unrevokable shape this replaces.
Closed/archived plans keep READ and lose EDIT (`PlanFreezeSharingHook`):
leaders and reporters must still see history, and sheets/snapshots are already
immutable.
F2 — `ensureRule` no longer touches the admin's controls: `active` is never in
the update payload, so a rule an admin switched off stays off; a `customized`
row is skipped whole; both cases log one line. The unconditional re-write of the
remaining fields stays, and is why: the platform skips share materialisation for
`isSystem` writes and re-asserting the rule is the sanctioned compensation.
F3 — a leader now gets a read rule on their subjects' results
(`{ plan, unit }`), not only their own `{ plan, person }` row. Department and
branch rows carry no `person` and are shared to unit members, which a leader is
not, so the README/docs promise of "所分管主体的结果" was previously empty.
F4 — `organization_id` is written on the update path too, repairing a rule that
was first created organization-less; a rule whose anchor unit has no
organization is refused loudly (`KPI_SHARING_NO_ORG`) instead of being written
tenant-wide.
F5 — the name lookup is organization-scoped, matching the object's
`unique: 'organization'` index, so a publish in one org can no longer update
another org's identically named rule.
F6 — the fixture's environment gate is an allowlist over the platform's own
determination (`NODE_ENV || 'production'`, the spelling shared by
`doctorNodeEnv`, `os start` and `serve`): dev/development/test only, unset means
production. NOTE: the platform seed loader has NO env gate at all
(`runtime/src/seed-loader.ts` contains no `env` reference), so there was no
seed-loader determination to mirror.
Also: `regenerateResults` in the adjustment hook is now wrapped like the others;
T39 asserts the exact derived rule count instead of a floor.
pnpm verify green; pnpm e2e 59/59 on a wiped DB, twice, against a freshly built
artifact.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
…build Same runtime as the 59/59 e2e run. The leader view now shows a single sheet because a leader is scoped to the plans that name them: C is the leader of the October plan only, and after that plan is closed the sheet stays visible with edit withdrawn -- the "keep read, drop edit" policy in the picture. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
Code review report (os-project-dev-review) — Round 2 — fix-point re-checkTier: full (fix points only; no re-review of settled areas) Library-specific checks: Findings — round-1 items: Round-1 ⚪ items also addressed: the adjustment hook's Regressions introduced by the fixes: none blocking. New writes are confined to two Findings (new, all record-only): Generated by Claude Code |
… reconcile coverage Six items from the PR #4 review, refined as work item #5: 1. Rule-name length bound. `sys_sharing_rule.name` is capped at 100 while the worst-case derived name was 116 (`kpi_p{plan}_result_leader_{unit}_{leader}` with three 32-char compliant ids). Every id fragment is now bounded at 26 via `RULE_SLUG_MAX`, so the longest shape is exactly 99. Over-long but otherwise compliant ids are truncated to 17 chars plus an 8-char stable hash; derivation stays idempotent across publishes. The existing fold for UUID-shaped ids (head 12 + stable hash) is deliberately untouched: changing it would rename every rule written before the upgrade, and the old names would sit neither in the desired set nor under the new plan prefix, so reconcile could never reclaim them again — the exact unreclaimable authorisation this module exists to prevent. A test pins that fold. 2. Legacy row backfill. New `src/data/backfill-plan-links.ts`, hooked on `kernel:bootstrapped` next to `align-demo-units`. Rows of `kpi_check_task` / `kpi_adjustment` created before the redundant `plan` column was written have an empty `plan` and therefore match no plan-scoped rule criteria. The backfill fills `plan` from the row's own entry sheet — only that one field, only rows where it is empty, resolved via the system context. Rows a hook refuses are counted and skipped, never written around. Idempotent: nothing to fill means zero writes and no log line. 3. Reconcile branch coverage. Unit tests for the legacy `kpi_share_*` deactivation branch in both directions (legacy rule present → deactivated; absent → nothing touched), plus other-plan and admin-customised rules staying out of range. 4. Log distinction. `skipped_inactive` splits into `skipped_inactive_admin` (an admin withdrew the grant in Setup) and `skipped_inactive_reconciled` (this app deactivated it during its own reconcile), told apart by a description prefix so no field is added to the platform object. 5. Null-organization refusal stays strict; the ruling and its date are recorded at the refusal site. 6. Re-assert fan-out is recorded as a known trade-off in a comment, not changed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb
Work item #2.
What changed
The 28 statically declared per-unit criteria sharing rules are gone. Data scope is derived from the plan configuration at runtime and written as sharing-rule data, per plan, so onboarding a customer organization takes no metadata change and a grant can be taken away again.
src/services/sharing-service.tssrc/objects/entry.object.tskpi_check_taskandkpi_adjustmentgain a storedplanlookup — a rule condition can only compare columns of its own objectsrc/hooks/plan.hook.tsPlanFreezeSharingHookdowngrades a closed/archived plan to read-onlysrc/hooks/sheet.hook.tsplanon check tasks; re-asserts the check-task and result rulessrc/hooks/adjustment.hook.tsplan; re-asserts result rules; rescoring is now non-blocking like everywhere elsesrc/hooks/bonus.hook.tsBonusAfterDecideHook— rescores results on a bonus decision (checklist #7)src/security/index.tsownscope; reporter row-level security widened to let non-person dimensions throughsrc/data/align-demo-units.tssrc/security/sharing-rules.tssharingRulesconfig key;requires: ['sharing']staystest/sharing.test.ts,scripts/e2e-flow.mjsWhy sharing rules rather than per-record shares
sys_record_shareis engine-owned (POST /api/v1/data/sys_record_share→405), and the grant entry point refuses every non-userrecipient — "group/position recipients are delivered via sharing rules (ADR-0111 D7)" — whilebuildReadFilterreads onlyuserrows. Unit-wide delivery is a sharing-rule concern by design: the evaluator expands one rule into per-user rows and recomputes them when unit membership or the unit tree moves.Rules are per plan, and reconciled
Keyed by unit alone, a rule is unrevokable: replace 市场部's leader and the old leader keeps edit on that unit's sheets in every future plan. So criteria carry the plan (
{ plan, subject },{ plan, branch },{ plan, unit },{ plan, person }), names are namespacedkpi_p<plan>_…, and publish reconciles — every kpi-managed rule of that plan not in the desired set is deactivated, along with any legacy plan-agnostickpi_share_*rule. Proven end to end by T54–T56: replace the leader on a new plan version, the old leader loses the new plan's sheets and keeps the old plan's.Closed / archived plans keep READ and lose EDIT. Leaders and reporters must still see history; sheets and snapshots are already immutable, so withdrawing edit is the same discipline expressed in the data scope. T57.
Two platform behaviours handled explicitly
isSystemwrites do not materialise shares — the platform says so inplugin-sharing's rule hooks and names re-evaluating the rule as the remedy. Every record in this app is created by a hook under the system context, so each trigger re-asserts the rules for the objects it just wrote. The re-assert never touchesactive(an admin's off switch stays off) and skips anycustomizedrow.Demo-fixture tenancy — temporary, tracked by objectstack-ai/objectstack#14547. Seeded
sys_business_unitrows carryorganization_id = NULLwhile a rule is stamped with the caller's organization, and recipient expansion compares them strictly.src/data/align-demo-units.tsstamps the seeded units the way an admin-created unit already is, atkernel:bootstrapped, gated by an allowlist over the platform's own determination (NODE_ENV || 'production'— unset means production, so the gate is default-closed) and narrowed to the ids this app seeds with a null organization. Delete the file and its call when #14547 ships.Verification
At
00253652abebf2741896f88335659996817585b1, against a freshly built artifact (pnpm build) and a wiped DB:pnpm verify— green (validate · typecheck · 55 unit tests, 5 files)pnpm e2e— 59 passed, 0 failed, run twiceNote on acceptance criterion 1
It expects a
sys_record_sharerow whose recipient is the unit. The platform delivers unit scope as per-user rows minted from the rule, so the row exists withrecipient_type: 'user',source: 'rule'— asserted by T40. Every other criterion passes as written.🤖 Generated with Claude Code
https://claude.ai/code/session_01VR2khJ3Me96btawVsfG6jb