Skip to content

Commit 2e8e037

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-4709-homepageid-tombstone-copy
2 parents ef8c17e + 7445149 commit 2e8e037

37 files changed

Lines changed: 2500 additions & 478 deletions
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): 视图 `searchableFields` 按运行时同一套判定做构建期校验 —— 一个 lookup 笔误不再等到 400 才暴露 (#4830)
6+
7+
视图(list view)的 `searchableFields` 会被客户端逐字回显为 `$searchFields` 覆盖参数,而
8+
REST 入口闸(#4254)会用 `resolveSearchFieldResolution`(`@objectstack/spec/data`)判定
9+
该对象的可搜索集合 —— 声明一个 lookup 等「不可搜索」字段,运行时会把**整条查询** 400
10+
(`INVALID_FIELD`),列表工具栏搜索对全体角色彻底不可用。此前 `compile`/`validate` 只查
11+
字段**存在性**,这类笔误全绿放行,只能靠人肉点搜索框发现。
12+
13+
新增规则 `searchable-field-unsearchable`(error 级,新导出常量同名):对每个视图级
14+
narrowing(对象内建 `listViews``defineView``list`/`listViews`、react 页面的
15+
`<ListView searchableFields>`)按**运行时同一个函数**(`resolveSearchFieldResolution`,
16+
非复制的类型清单,杜绝再度漂移)判定 declared = enforced:
17+
18+
- 对象未声明 `searchableFields`(auto 源):视图里出现 lookup/json/hidden/审计列等
19+
auto-default 拒绝的字段 → 构建期 error,信息含类型与 400 后果,lookup 给出「镜像到本
20+
对象 text/formula 字段」的处方;
21+
- 对象已声明(declared 源):视图条目超出对象声明集合 → 构建期 error(视图只能收窄、
22+
不能放宽,ADR-0061);
23+
- 对象自身的 `searchableFields`(canonical)维持**只查存在性**:运行时 declared 分支按
24+
存在过滤、不按类型过滤,声明即被引擎执行,构建期拒绝会误伤运行时接受的元数据
25+
(ADR-0072 D1);
26+
- 注册表注入的系统列在 narrowing 中跳过判定(其运行时元数据对 linter 不可见,宁可漏报
27+
不可误报)。
28+
29+
内部核心 `checkSearchableFieldList` / `indexObjectSearchTargets`(模块级导出,未入包
30+
barrel)签名有变:索引值从 `Set<string> | null` 变为 `ObjectSearchTarget | null`,并新增
31+
可选 `role: 'canonical' | 'narrowing'`(默认 `'narrowing'`)参数。
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
"@objectstack/spec": major
3+
---
4+
5+
refactor(spec)!: remove `activationEvents` (both keys) and the `ActivationEventSchema` vocabulary — lazy activation that no runtime ever implemented (#4657, ADR-0049)
6+
7+
`activationEvents` promised lazy plugin activation ("plugins remain dormant
8+
until an activation event fires") on two authorable surfaces —
9+
`DynamicLoadRequest.activationEvents` (`@objectstack/spec/kernel`) and
10+
`StudioPluginManifest.activationEvents` (`@objectstack/spec/studio`, the
11+
`defineStudioPlugin` input) — and **no runtime in objectstack / cloud /
12+
cloud-v1 / objectui ever read either key** (four-repo bare-name scan in #4657,
13+
re-verified at implementation time). Every plugin has always activated
14+
immediately on load/registration; cloud-v1's own ROADMAP recorded lazy
15+
activation as ❌ unimplemented (planned v0.4.0). That is ADR-0049's
16+
declared ≠ enforced shape in the semantically-lying direction: an author
17+
writing `activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }]`
18+
expected deferral and got eager activation with a clean parse.
19+
20+
#4653 had just converged the two `ActivationEventSchema` declarations onto one
21+
structured `{ type, pattern }` form inside this same unreleased major; with the
22+
enforce-or-remove ruling landing on **remove**, that converged vocabulary
23+
retires before ever shipping. Composed across the two changes, a v16 author
24+
simply deletes the key in whichever form they carried.
25+
26+
Migration (FROM → TO):
27+
28+
- `activationEvents` in a `defineStudioPlugin` input / `StudioPluginManifest`
29+
value — v16 string form (`['*']`, `['onMetadataType:flow']`) or v17-rc
30+
structured form (`[{ type: 'onStartup', pattern: '*' }]`) alike →
31+
**delete the key**. There is no replacement value: eager activation is the
32+
only behaviour there has ever been, and `activate()` still runs at
33+
registration time. The strict manifest parse rejects the key (and its former
34+
VS Code-flavoured aliases `activation` / `events` / `onActivate`) with this
35+
prescription.
36+
- `activationEvents` in a `DynamicLoadRequest` value → **delete the key**.
37+
Tombstoned, not silently stripped — `DynamicLoadRequestSchema` is not
38+
`.strict()`, so a `retiredKey()` tombstone makes authoring it a `tsc` error
39+
and a parse error carrying the prescription.
40+
- `import { ActivationEventSchema, ActivationEvent } from '@objectstack/spec/kernel'`
41+
(or `/studio`) → **no replacement export** (TS2305 after upgrade). Nothing
42+
consumed the vocabulary; an exported schema with no consumer is read as a
43+
capability by whoever finds it (#3950), so the orphaned def goes with the
44+
keys.
45+
- Lazy activation is a **new capability**: if it is ever built it returns via
46+
the enforce route of ADR-0049 through a new ADR — executor first, vocabulary
47+
second — not by re-declaring inert keys.
48+
49+
Self-check (#4535 §5): TS2305 — yes, two removed exports on two entries;
50+
metadata migration — none possible or needed (`StudioPluginManifest` is TS
51+
configuration parsed by `defineStudioPlugin`, a root schema never stored in
52+
`sys_metadata`; `DynamicLoadRequest` is a runtime request shape with no
53+
caller — no stored row exists for a D2 conversion to rewrite, so the change is
54+
one ADR-0087 D3 semantic record, `plugin-activation-events-retired`); shape
55+
change — two keys removed, zero behaviour change (eager activation before and
56+
after, byte-identical).
57+
58+
The retirement kit: `retiredKey()` tombstone on the non-strict kernel schema;
59+
strict-parse `guidance` prescriptions on the studio manifest (including the
60+
three former aliases); ADR-0087 D3 semantic migration; baselines
61+
(`authorable-surface.json` — one `[RETIRED]` line, five lines dropped
62+
deliberately with the defs; `json-schema.manifest.json``kernel/ActivationEvent`
63+
and `studio/ActivationEvent` def removals; `api-surface.json`) regenerated
64+
deliberately; compiler-API export pin (`activation-events-retirement.test.ts`,
65+
zero holders across every public entry) — sabotage-verified.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
Tooling-only: `pnpm check:startup-registry-verdict` — startup registry reads may not record a verdict the boot can still contradict (#4777). Adds `scripts/check-startup-registry-verdict.mjs` + the shrink-only `scripts/startup-registry-verdict.baseline.json` (empty on landing), a `Lint & Type Check` step, and an AGENTS.md section. Releases nothing — no package changes.
5+
6+
One showcase cold start on 2026-08-03 produced three instances of one shape in three unrelated subsystems written by three people at three times: ask a registry "is X there?" while the boot is still filling it, treat the "no" as final, and **record** it — cached on the instance (#4772 plugin-auth), asserted in a `warn` (#4771 service-automation), or written to the database (#4769 objectql). The provider registers a moment later and nothing undoes the record. All three are fixed; this is what stops the class from coming back.
7+
8+
The gate matches the three-part shape, and part 3 is what makes it a rule rather than noise — a read-only probe stays completely legal, and the cures are never flagged: a probe deferred into a lazy accessor or a `kernel:ready` hook, a probe whose ordering an ADR-0116 declaration (`dependencies` / `optionalDependencies` / `requiresServices`) has already made final, and a verdict drawn at a declared seal (`sealNodeTypeVocabulary()`) all pass.
9+
10+
Its reach is stated rather than implied: it under-matches on purpose. `getService('cache')` is visible, a `resolveCacheOrFallback()` three layers down another package is not, and #4769 is invisible to it entirely — that "registry" is the `sys_migration` table in a database. This stops the bleeding; it does not cure. Whether the kernel contract should be tightened further is #4776.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
feat(plugin-audit)!: retire `sys_comment.visibility` and `sys_comment.reply_count` (#4756, ADR-0049)
6+
7+
Both fields were modelled with **zero** runtime consumers — nothing in this repo,
8+
in `objectui`, or in `cloud` ever read or maintained either one. ADR-0049
9+
enforce-or-remove; maintainer decision: remove both. Same disposition, and for
10+
the same stated reason, as `sys_attachment.share_type` / `sys_attachment.visibility`
11+
in #2755 ("attachment access is derived from the parent record").
12+
13+
**REMOVED — `sys_comment.visibility`** (`'public' | 'internal' | 'private'`,
14+
defaulted `'public'`).
15+
16+
This one is a **security-looking key with no gate behind it**, which is the
17+
primary reason it goes rather than stays. No code path consulted it: not
18+
`enforceFeedsCapability`, not the record-level gates added in #4630, not the
19+
REST layer, not objectui's discussion panel. A comment an author marked
20+
`private` was visible to exactly the same people as a `public` one — an app
21+
author (or an AI authoring metadata) reading the field list would reasonably
22+
believe otherwise, and get a silent security failure instead of an error. That
23+
is the Prime Directive #10 trap in its textbook shape.
24+
25+
There is **no replacement key**: after #4630, who can see a comment is decided
26+
by the record-level permissions of the record its `thread_id` names — one
27+
coherent rule. A per-row enum layered on top would be a second source of truth
28+
for the same question. The enum's only genuinely missing meaning ("hidden from
29+
external/portal principals") depends on external principals existing at all,
30+
which waits on ADR-0090 D11's `externalSharingModel`; today there is nobody to
31+
hide a comment from. This does not foreclose that design — when portals land,
32+
a visibility key can return **enforce-first**, with a real gate and tests.
33+
34+
**FROM → TO:** stop sending `visibility` on `sys_comment` writes; to restrict
35+
who sees a discussion, restrict who can read the record `thread_id` points at.
36+
37+
**REMOVED — `sys_comment.reply_count`** (`number`, `defaultValue: 0`,
38+
`readonly: true`).
39+
40+
Never incremented anywhere, and `readonly` meant an author could not set it by
41+
hand either, so every row read `0` forever — a UI binding an "N replies" badge
42+
to it rendered `0` for every thread. Deliberately **not** replaced by an
43+
`afterInsert`/`afterDelete` roll-up: the predicate/bulk write-hook gaps tracked
44+
by #4770 / #4778 / #4779 (a hook that returns early without a single-record id
45+
lets the whole bulk operation through) are exactly where a hook-maintained
46+
counter drifts — a bulk delete of replies would never decrement it. A counter
47+
that drifts is worse than no counter, because both the UI and an AI reading the
48+
record trust it. If a badge needs the number, aggregate `parent_id` children at
49+
read time; a designed roll-up can be revisited once #4775's family has settled
50+
bulk-hook semantics.
51+
52+
**FROM → TO:** replace reads of `reply_count` with a count of `sys_comment` rows
53+
whose `parent_id` is the comment's id.
54+
55+
**Stored data.** Existing databases keep both columns as **unmanaged leftovers**
56+
— no migration, matching #2755. What changes where:
57+
58+
- **Reads are loud everywhere.** The read-axis gates (#4134 / #4226) resolve
59+
field names from the object schema, not from the table, so a filter, sort,
60+
`select` or `expand` naming `visibility` / `reply_count` now answers
61+
`400 INVALID_FIELD` on every deployment, leftover column or not. A "0 replies"
62+
badge that silently lied becomes an error that names itself.
63+
- **Writes are loud on new databases only.** A database provisioned after this
64+
change has no such column, so the write fails at the driver and is mapped to
65+
the same `400 INVALID_FIELD` envelope. On a pre-existing database the leftover
66+
column still accepts a value nothing will ever read — record validation does
67+
not reject undeclared keys. Dropping the two columns is an optional manual
68+
cleanup, not a requirement.

.github/workflows/lint.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,24 @@ jobs:
204204
- name: Durability-degradation log-level guard
205205
run: pnpm check:durability-log-level
206206

207+
# Startup registry-verdict guard (#4777). One showcase cold start produced
208+
# three instances of one shape in three unrelated subsystems: ask a
209+
# registry "is X there?" while the boot is still filling it, treat the
210+
# "no" as final, and RECORD it — cached on the instance (#4772
211+
# plugin-auth), asserted in a warn (#4771 service-automation), or written
212+
# to the database (#4769 objectql). The provider registers a moment later
213+
# and nothing undoes the record. This gate walks the AST for that
214+
# three-part shape: a pre-ready registry read (init()/constructor, or an
215+
# ADR-0018 open capability registry before its seal) whose absent verdict
216+
# is recorded. Reading is legal and must stay legal — a probe licensed by
217+
# an ADR-0116 declaration, or one deferred into a closure/kernel:ready
218+
# hook, is exactly the fix and is never flagged. Deliberately narrow: it
219+
# cannot see a probe behind an indirection in another package, and it
220+
# cannot see #4769 at all (that "registry" is a database table). Runs its
221+
# own --self-test first.
222+
- name: Startup registry-verdict guard
223+
run: pnpm check:startup-registry-verdict
224+
207225
# Release-notes drift guard: the platform is one version-locked train, so
208226
# every released @objectstack/spec major must have a curated, navigable
209227
# release page at content/docs/releases/v<major>.mdx. Catches the gap that

AGENTS.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,72 @@ shrink-only.
601601

602602
---
603603

604+
## Startup registry reads — never record a verdict the boot can still contradict
605+
606+
A boot fills its registries incrementally. Asking a registry "is X there?" while
607+
it is still filling is fine — the answer is simply not final yet. Turning that
608+
not-yet into a **verdict and recording the verdict** is the defect, because the
609+
provider registers a moment later and nothing goes back to undo the record.
610+
611+
Decide with **one question**, the counterpart of the degradation-log-level one:
612+
613+
> **At the moment this code concludes "X is not registered", can a provider
614+
> still register X during this same boot? And is that conclusion RECORDED
615+
> anywhere that outlives the moment?**
616+
> **Yes and yes → defect.**
617+
618+
Three parts, all three or it is not a finding:
619+
620+
1. a read of a registry that is still filling — the service registry during
621+
`init()`, or a plugin-extensible capability registry before it is sealed;
622+
2. a terminal conclusion drawn from "absent";
623+
3. that conclusion **recorded** — cached in an instance field or module binding,
624+
asserted in a `warn`, or persisted.
625+
626+
Part 3 is what makes this a rule and not noise. **A read-only probe is
627+
completely legal**: `AutomationEngine.getUnknownNodeTypeAudit()` reads the
628+
executor registry on every call, records nothing, and is correct.
629+
630+
**Why this is a rule and not a preference.** One showcase cold start on
631+
2026-08-03 produced three instances, in three unrelated subsystems, written by
632+
three people at three times: plugin-auth froze an `undefined` cache handle into
633+
its config for the life of the process, so rate-limit counters never reached the
634+
shared store and the printed warning sent operators to provision Redis for a
635+
problem they did not have (#4772); service-automation asserted that eight
636+
approval flows "will fail at execution time" 0.8s before the executor that runs
637+
them was registered, and a deployment that genuinely lacked the plugin emitted
638+
the identical eight, so the signal could not tell the two apart (#4771); objectql
639+
wrote an ADR-0104 attestation into `sys_migration` during the same boot that was
640+
still seeding rows contradicting it, so the next restart rejected its
641+
predecessor's data (#4769). Whether the kernel contract itself should be
642+
tightened further is #4776.
643+
644+
**The three cures, in preference order:**
645+
646+
1. **Resolve where it is used, not where you start.** A lazy accessor or a
647+
`kernel:ready` hook sees a provider that registered later —
648+
`createLazyCacheRateLimitStorage()` in plugin-auth is the reference.
649+
2. **Declare the ordering (ADR-0116).** `dependencies` / `optionalDependencies`
650+
/ `requiresServices` make the kernel hoist the provider ahead or assert it
651+
registered, which makes "absent" a *fact*. Tolerance belongs in the plugin's
652+
own declaration, where the kernel enforces it — not in a checker's ledger.
653+
3. **Seal the vocabulary, then judge.** For a registry that is open by contract
654+
(ADR-0018 flow node types), the host declares the moment it can no longer
655+
grow — `AutomationEngine.sealNodeTypeVocabulary()`, called at
656+
`kernel:bootstrapped` — and only then is an absence worth reporting.
657+
658+
**It has teeth**: `pnpm check:startup-registry-verdict` walks the AST for that
659+
three-part shape and fails on it; accepted exceptions live in the shrink-only,
660+
hand-edited `scripts/startup-registry-verdict.baseline.json`. Like
661+
`check:durability-log-level` it is deliberately narrow — it cannot *discover* a
662+
new seam, only stop known ones from regressing, and it under-matches on purpose
663+
rather than risk a false positive: `getService('cache')` is visible, a
664+
`resolveCacheOrFallback()` three layers down another package is not, and #4769's
665+
"registry" is a database table it can never see. Found a new open registry? Add
666+
it to `OPEN_CAPABILITY_REGISTRIES` in the same PR that fixes it.
667+
668+
---
669+
604670
## Post-Task Checklist
605671

606672
1. `pnpm test` — verify nothing broke. Touched a type-check-covered package? `pnpm typecheck` too.

content/docs/plugins/development.mdx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,6 @@ export const manifest = defineStudioPlugin({
384384
name: 'Flow Designer',
385385
version: '2.0.0',
386386
description: 'Visual flow builder for automation workflows',
387-
activationEvents: [{ type: 'onMetadataType', pattern: 'flow' }],
388387

389388
contributes: {
390389
metadataViewers: [{
@@ -434,16 +433,13 @@ export const manifest = defineStudioPlugin({
434433
});
435434
```
436435

437-
### Activation Events
436+
### Activation
438437

439-
Control when your plugin loads with activation events:
440-
441-
| Pattern | Trigger |
442-
|:---|:---|
443-
| `*` | Activate immediately (eager, default) |
444-
| `onMetadataType:object` | When metadata type "object" is loaded |
445-
| `onCommand:myPlugin.doSomething` | When command is invoked |
446-
| `onView:myPlugin.myPanel` | When panel is opened |
438+
Every Studio plugin loads and activates immediately on registration — `activate()`
439+
runs at registration time, unconditionally. The former `activationEvents` manifest
440+
key was removed in v17 (#4657, ADR-0049): it declared lazy activation that no
441+
Studio host ever implemented, so a manifest that still carries it now fails the
442+
parse with the upgrade prescription. Delete the key.
447443

448444
### View Modes
449445

0 commit comments

Comments
 (0)