Skip to content

feat(lint,metadata-protocol): judge a package write against its own closure - #10058

Draft
os-elon wants to merge 2 commits into
mainfrom
claude/issue-9612-publish-gate-package-closure
Draft

feat(lint,metadata-protocol): judge a package write against its own closure#10058
os-elon wants to merge 2 commits into
mainfrom
claude/issue-9612-publish-gate-package-closure

Conversation

@os-elon

Copy link
Copy Markdown
Collaborator

Fixes#9612

Implements the re-scoped card in comment 5346052895 — not the issue body, whose menu of three routes (size threshold / caching + invalidation / per-rule scoping) it supersedes. Sibling #9613 is folded in.

This is not "make publish faster". It is validate a package write against the package's own closure. The latency numbers below are how the closure is confirmed correct; they are not the point. Maintainer, verbatim and standing: 「目前也没有相关的真实用户」 · 「objectstack cloud 还没有正式上线,简化开发」. No tenant is waiting on this.

The ruling the whole change hangs on:

大客户(420 个对象),就不应该出现在一个软件包中啊,这就是划分软件包的价值。
客户开发开发,校验是否也应该基于软件包
当然这里面要考虑系统对象


FIRST DELIVERABLE — the object door, measured

The card required this before any implementation, and forbade assuming the flow door's 80–99% transfers. Every figure below names its shape and its N.

Instrumentscripts/bench/runtime-publish-gate.bench.mts. Registry state proved live in the built artifact before any number was read:

$ node scripts/ablation-dist-preflight.mjs @objectstack/lint 'validateRuleCompilability'
✓ marker present in 6 built files (plus 4 sourcemap hits, not counted)
-- the ablation is live in the artifact the suite consumes.

1. The object door's driver, reproduced (--mode per-rule --type object)

rules dispatched (7): validateFunctionalCompleteness, validateManagedApiMethods, validatePresetComparands, lintAutonumberFormats, validateSecurityPosture, validateRuleCompilability, validateRuleSchemaFormats

shape / Nwhole gatevalidateRuleCompilabilityvalidatePresetComparands
real 2118.02 ms13.34 ms (74.0%)1.10 ms (6.1%)
real 10579.69 ms61.28 ms (76.9%)6.42 ms (8.1%)
real 420280.51 ms226.40 ms (80.7%)27.74 ms (9.9%)
stub 4207.81 ms0.04 ms (0.5%)1.96 ms (25.2%)

The card's 81.3% of 275.81 ms reproduces at 80.7% of 280.51 ms. validatePresetComparands is 9.9% here — a rounding error against the driver, and the card was right to fence the scope away from it.

Why it narrows, from the source rather than from the number:validateRuleCompilability reaches objects through walkObjectValidationRules(stack), which iterates stack.objects whole and hands every json_schema / format rule on every object to a real ajv compile — twice per publish, once per differential pass. It is not reading "the object being written"; it is reading the tenant.

2. Narrowing by package closure, the shipped path (--mode package)

A new bench mode. Unlike --mode closure, whose docblock declares it a HYPOTHETICAL, this one calls the shippednarrowObjectsToPackageClosure on a packaged seed, so the number describes shipped behaviour. Seed: 22 objects per package, plus one platform object owned by a package nobody declares.

Closure = own package + declared dep + platform/system + unpackaged = 45 of 421 (10.7%) — inside the card's 6.2–15.2% reference band.

doorshape / Nfullpackage closuresaving
objectreal 2230.43 ms27.17 ms10.7% (closure = 100%, nothing to narrow)
objectreal 10685.52 ms42.66 ms50.1%
objectreal 421312.94 ms41.89 ms86.6%
objectstub 42122.19 ms13.62 ms38.6%
flowreal 2242.84 ms35.71 ms16.6%
flowreal 106160.14 ms60.58 ms62.2%
flowreal 421578.56 ms62.28 ms89.2%
flowstub 42125.86 ms4.46 ms82.8%

The object door does narrow — and it was not safe to assume so: it narrows by a different mechanism (one rule compiling every object's schemas) than the flow door (seven rules walking every object's expressions). The residual is a floor, not a proportion: the closure leg costs ~42 ms at N=106 and ~42 ms at N=421.

⚠️ Below N=45 the closure is the whole tenant and the saving is nil by construction, which is the correct behaviour, not a gap: a tenant that small is one package.

3. Differential verdict — and one honest caveat

Semantically UNCHANGED at every N, both shapes, both doors.

⚠️ The raw comparison including the finding path reports +1 / -1 on the object door whenever narrowing occurs. That is not a verdict change: object-door findings carry an index-based path (objects[417].sharingModel), narrowing moves the written item's index, and where already carries the object name. Both readings are printed by the bench rather than the inconvenient one being hidden — the index really is wire-visible in issues[].path, and it is an index into a snapshot array no caller has ever seen.

4. Positive controls — and where they are silent

controlflow door, real 421object door, real 421
A — written item's own package dropped+1 phantom findingno delta
B — system objects dropped−4 findings lostno delta

Control A reproduces the #7886 mechanism on objects: flow-trigger-unknown-object on a target that is really there. Control B shows the "system objects unconditionally IN" limb is load-bearing in the other direction — omitting it loses real findings.

⚠️Both controls are silent on the object door, and that is a finding, not a pass. Source-verified: none of the seven object-door rules builds a name→object index or resolves a reference across objectsvalidateSecurityPosture crosses objects × permissions and objects × books, never objects × objects; lintAutonumberFormats's only Map is per-object field metadata. The object door has no objects×objects coupling at all, so on that door a verdict-equivalence assertion cannot fail for closure reasons. Stated plainly because an unfalsifiable green must not be read as a measured one. The ablations in the test file therefore live on the flow door, where the coupling exists.


What ships

packages/lint/src/runtime-gate.tsbuildRuntimeWriteSnapshots takes an optional packageScope and reduces objects to the written item's package closure. In the snapshot builder deliberately: every gated write type is built through it, so this is one change covering both doors and they cannot drift into two policies. Applied to both passes — the verdict is candidate minus baseline, so narrowing one side would be a different question, not a smaller one.

The four limbs, each load-bearing: the written package · the transitive closure of its declaredmanifest.dependencies · platform/system objects unconditionally (via the security rule's own isSystemObject, now exported so there is one reading of "system" and not two) · rows with no package provenance, including the sys_metadata rehydration sentinel.

packages/metadata-protocol/src/protocol.tsresolveWritePackageScope walks the package registry for that dependency closure; assertRuntimeAuthoringRules hands it to the gate. Wired at saveMetaItem (request.packageId) and at the promotion door (promoteDraftForPublish's caller-stated binding, which publishPackageDrafts sets).

The fallback direction is the whole design

A write that names no package, names the sys_metadata sentinel, or names one the registry cannot produce narrows nothing and is judged exactly as before. An unresolvable package buys a write more validation input, never less. ⛔ There is no branch that skips rules and none that skips them past a size — the fail-open at scale (#9798 / #9261 / ADR-0110 D3) the card refused is not in this diff, and narrowObjectsToPackageClosure has no N in it at all.

One behaviour change, pinned as a decision

A package-scoped write referencing an object in a package it never declared a dependency on is now judged against a closure that omits it, so the reference is reported. That is the ruling's intended consequence — such a reference is not resolvable by declaration — it applies only to writes that state a package, and on the rule measured here it is advisory (warning), not a refusal. Pinned by name in protocol.package-closure-gate.test.ts so it is on the record rather than a surprise.

Falsified premise, not re-inherited

#7886's 38-vs-4 phantoms came from narrowing permissions. This narrows objects. The ablation in the test file reproduces the phantom mechanism on objects and shows what actually manufactures one: a closure missing a limb — not narrowing per se. 已证伪.

Deliberately NOT here

⛔ No caching layer (refuted: construction is 0.001–0.011 ms). ⛔ No size threshold. ⛔ No new configuration surface. ⛔ No new tenant-facing API. ⛔ --mode closure's generous reference-closure deriver was not lifted into shipping code — its docblock says it bounds a hypothetical, and the shipped closure is a different, declared thing.

Not narrowed today — filed, not widened

publishMetaItem (single-item draft→active) states no package, so it narrows nothing. The draft row's package is in sys_metadata.package_id, but rowToItem projects it into a MetadataItem, which carries no package id — reading it there would have been a narrowing that never fires while looking like it does. Widening MetadataItem is a packages/spec contract change and out of this card's fence.

Checks — every one actually run, at 112c3ffc3

checkresult
pnpm --filter @objectstack/lint test77 files / 2133 tests passed (13 new)
pnpm --filter @objectstack/metadata-protocol test124 passed, 2 skipped / 1708 passed, 10 skipped (6 new, confirmed by name under --reporter=verbose)
pnpm --filter @objectstack/lint typecheckDone
tsc --noEmit -p packages/metadata-protocol63 pre-existing errors, 0 in any file this PR touches (package is in the TEST_DEBT ledger; it has no typecheck script)
eslint over all 9 touched filesexit 0
check:type-check-debt --re-measureOK — 33 ledger entr(ies) re-measured, 1924 raw tsc error(s) total, none above its recorded number
check:engine-double-contractOK — 323 pinned, 133 in the DEBT ledger, 2 exempt
check:nul-bytes · check:slot-lookup · check:where-matcher · check:query-options-erasure · check:type-check-coverage · check:cross-package-test-inputs · check:durability-log-level · check:filter-alias-parity · check:changeset-gate-self-tests · check:objectui-changeset · check-adr-0087-registration · check-changeset-no-major · check-empty-changeset · check-affected-docs · check-role-wordall exit 0

Gate set derived at the final commit with node scripts/pm/dispatch-gates.mjs (no hand-built path list). Exit codes captured before any pipe.

Ledger discipline:scripts/engine-double-contract.pinned.json gains exactly two rows, both pinned coverage for the new test file, via the gate's own --write; its output reads No pin losses — this regeneration only records new or grown coverage. No shrink-only baseline moved in either direction, and no ratchet was raised.

In-flight

Every remote head diffed against origin/main and filtered to my file surface. Two hits: changeset-release/main (generated CHANGELOG/package.json only) and claude/issue-9313-reference-integrity-view-surface, which touches runtime-gate.ts (one line in runRuntimeAuthoringRules, not my region) and flips the reference-integrity suite to ['flow','view'] — flow/view door only, no object-door entry moved. main merged at 20b9a9ce1 before opening; it touched neither packages/lint/ nor packages/metadata-protocol/.

Draft on purpose — the PM lands this through the merge queue after review. ⛔ Not merged, auto-merge not armed.


Generated by Claude Code

…losure (#9612)
The runtime publish gate handed every rule the tenant's entire `objects`
collection on every publish. Per the maintainer's ruling the validation unit is
the package, not the tenant: `buildRuntimeWriteSnapshots` now accepts an
optional `packageScope` and reduces `objects` to the written package + its
transitively declared dependencies + platform/system objects + unpackaged
overlay rows. `assertRuntimeAuthoringRules` resolves that scope from the
package registry.
A write that names no package — or names one the registry cannot produce —
narrows nothing and is judged exactly as before. There is no branch that skips
rules and none that skips them past a size.
Measured on `scripts/bench/runtime-publish-gate.bench.mts --mode package`,
real-shape seed, packaged 421-object tenant, closure 45/421 = 10.7%: object
door 312.94 ms -> 41.89 ms; flow door 578.56 ms -> 62.28 ms. The differential
verdict is unchanged at every N and both shapes; two ablations prove the check
can fail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019yDEhPBC3tcGkW9bkce1HM
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/metadata-protocol, touching 19 documentable anchor(s).

17 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 2a6ebaf519535a3332b50ddd90d081de8e135a4f.

4 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/lint/src/runtime.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 45 of 221 client-bound route-ledger rows — the other 176 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run: node scripts/docs-audit/affected-docs.mjs --bridge-coverage

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2a6ebaf519535a3332b50ddd90d081de8e135a4fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from dcee04c0f1e2e8f239b615aa75434529122a0a09 — the merge of head 112c3ffc3106d144f12fe5917a0c44bd4761adac into base 2a6ebaf519535a3332b50ddd90d081de8e135a4f, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dcee04c0f1e2e8f239b615aa75434529122a0a09 && git checkout dcee04c0f1e2e8f239b615aa75434529122a0a09
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2a6ebaf519535a3332b50ddd90d081de8e135a4f 112c3ffc3106d144f12fe5917a0c44bd4761adac && git checkout -B drift-repro 2a6ebaf519535a3332b50ddd90d081de8e135a4f && git merge --no-ff 112c3ffc3106d144f12fe5917a0c44bd4761adac
node scripts/docs-audit/affected-docs.mjs --json 2a6ebaf519535a3332b50ddd90d081de8e135a4f

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 2a6ebaf519535a3332b50ddd90d081de8e135a4f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/xlteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime gate: full runtime-safe rule snapshot for the publish door — the expensive half split out of the object-gating card

2 participants

@os-elon@claude