Filed unassigned from the domain:services seat (#6021) while reviewing PR #8483 against the maintainer's 2026-08-06 ruling on #4953. ⛔ This records a finding only — no fix commitment, no pm:queue.
What is there
packages/plugins/plugin-sharing/src/share-link-service.ts defines its own declared-field binder:
functionbindDeclaredFields(record: Record<string,unknown>,schema: any): Record<string,unknown>{constdeclared=schema?.fields;if(!declared||typeofdeclared!=='object')returnrecord;constbound: Record<string,unknown>={ ...record};for(constnameofObject.keys(declared)){if(!(nameinbound))bound[name]=null;}returnbound;}Its own doc comment names what it is a copy of:
"The same materialisation the two server-side CEL gates in @objectstack/objectql do (materializeDeclaredFields, behind the validation and hook-condition evaluators)."
It is used by assertEligible to bind the record before evaluating publicSharing.eligibility.
Why this is a finding rather than a style nit
It could have imported the real helper.plugin-sharing/package.json lists @objectstack/objectql as a runtime dependencies entry (not a devDependency), and materializeDeclaredFields is exported from packages/objectql/src/declared-fields.ts. There is no structural obstacle here — unlike trigger-record-change, where objectql is dev-only.
The repo has already written down why duplication is the failure.packages/objectql/src/cel-fault.ts:
"the only durable way to keep two messages worded alike is to stop writing them twice — the same argument that put declared-fields.js#materializeDeclaredFields in front of every server-side evaluator"
It has already diverged. The local copy early-returns recordunchanged when schema.fields is absent or not an object. The shared helper is not reached that way by its callers, which gate on the groundTruth rule instead (fetch-the-prior-row, "do not fabricate"). So the two answer differently in at least one input class today.
⚠️The live exposure is the pinned !has(...) reversal. PR #6454 deliberately changed what !has(record.{declared field}) means once bindings are materialised, pinned by two tests, on the grounds that has() guards an UNDECLARED key and never an empty value (the declared-fields.ts contract since #4649). A hand-rolled mirror does not inherit that change, nor the next one. The entire point of the seam programme in #4953 is that one predicate means one thing on every server-side surface.
Blast radius, stated honestly
⭐ This is not a live security hole, and it should not be graded as one.assertEligible fails closed: an unevaluable predicate raises ELIGIBILITY_UNEVALUABLE and refuses to mint the link. So the divergence's current worst case is a refused share link, not a leaked one. The bindDeclaredFields bail on a missing schema lands in the same safe direction.
The cost is drift, not breach: this is the surface where a future change to the shared contract silently fails to apply, in a gate whose verdicts are security-relevant.
Suggested shape (⛔ not a prescription — the owning lane decides)
The convergent option, which also resolves the same question now open on PR #8483: move materializeDeclaredFields into @objectstack/core, which objectql, plugin-sharing and trigger-record-change all already depend on at runtime, and re-export from objectql for compatibility. That lets all three seams share one copy without promoting any dependency.
Counting the seams for whoever picks this up: objectql has 2 importers (hook-wrappers.ts, validation/rule-validator.ts); this file is copy #2 of the pattern; PR #8483 proposes copy #3.
Related
Verified by reading share-link-service.ts and both package.json files on main, not by code search alone.
Filed unassigned from the
domain:servicesseat (#6021) while reviewing PR #8483 against the maintainer's 2026-08-06 ruling on #4953. ⛔ This records a finding only — no fix commitment, nopm:queue.What is there
packages/plugins/plugin-sharing/src/share-link-service.tsdefines its own declared-field binder:Its own doc comment names what it is a copy of:
It is used by
assertEligibleto bind the record before evaluatingpublicSharing.eligibility.Why this is a finding rather than a style nit
It could have imported the real helper.
plugin-sharing/package.jsonlists@objectstack/objectqlas a runtimedependenciesentry (not a devDependency), andmaterializeDeclaredFieldsis exported frompackages/objectql/src/declared-fields.ts. There is no structural obstacle here — unliketrigger-record-change, where objectql is dev-only.The repo has already written down why duplication is the failure.
packages/objectql/src/cel-fault.ts:It has already diverged. The local copy early-returns
recordunchanged whenschema.fieldsis absent or not an object. The shared helper is not reached that way by its callers, which gate on thegroundTruthrule instead (fetch-the-prior-row, "do not fabricate"). So the two answer differently in at least one input class today.!has(...)reversal. PR #6454 deliberately changed what!has(record.{declared field})means once bindings are materialised, pinned by two tests, on the grounds thathas()guards an UNDECLARED key and never an empty value (thedeclared-fields.tscontract since #4649). A hand-rolled mirror does not inherit that change, nor the next one. The entire point of the seam programme in #4953 is that one predicate means one thing on every server-side surface.Blast radius, stated honestly
⭐ This is not a live security hole, and it should not be graded as one.
assertEligiblefails closed: an unevaluable predicate raisesELIGIBILITY_UNEVALUABLEand refuses to mint the link. So the divergence's current worst case is a refused share link, not a leaked one. ThebindDeclaredFieldsbail on a missing schema lands in the same safe direction.The cost is drift, not breach: this is the surface where a future change to the shared contract silently fails to apply, in a gate whose verdicts are security-relevant.
Suggested shape (⛔ not a prescription — the owning lane decides)
The convergent option, which also resolves the same question now open on PR #8483: move
materializeDeclaredFieldsinto@objectstack/core, whichobjectql,plugin-sharingandtrigger-record-changeall already depend on at runtime, and re-export fromobjectqlfor compatibility. That lets all three seams share one copy without promoting any dependency.Counting the seams for whoever picks this up: objectql has 2 importers (
hook-wrappers.ts,validation/rule-validator.ts); this file is copy #2 of the pattern; PR #8483 proposes copy #3.Related
readonlyWhen求值补materializeDeclaredFields—— 服务端第三个接缝 (#4953) #6454 — thereadonlyWhenseam and the!has(...)pinvalidate-null-guards.ts的接缝台账在 PR #6454 合并后会说假话 —— 字段readonlyWhen那行仍写着 "sparse / never materializes" #6458 — the seam-ledger staleness this family keeps producingVerified by reading
share-link-service.tsand bothpackage.jsonfiles onmain, not by code search alone.