Found while implementing objectstack-ai/hotcrm#1104 (declaring publicSharing on a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.
What was measured
Read on the installed @objectstack/plugin-sharing@17.1.0, dist/index.js:
ShareLinkService.createLink() calls assertEligible(eligibility, exists[0], schema, object) before writing the sys_share_link row. Verified behaviourally: a draft and an internal-audience record are refused with RECORD_NOT_ELIGIBLE / 422 and no row is written. This is objectstack#7861 working as shipped.ShareLinkService.resolveToken() does not evaluate the predicate. It checks revoked_at, expires_at, the signed_in / email audience gates, the password, and recordStillExists(object, recordId) — existence only, under the system context. getPolicy() is called there solely to collect redactFields.
So the eligibility policy is a mint-time gate, not a serving-time one.
Why that matters
The state the predicate reads is mutable, and it is exactly the state an editor changes:
- article is
published + public → an author mints a public link → the link is handed out; - someone notices it contains internal detail and flips
audience to internal (or status back to draft); - the object's declared policy now says the record is not eligible for link sharing;
- the existing token still resolves, and the record is still served in full to a caller with no principal.
The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.
This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour: recordStillExists is deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.
Options
A. Re-evaluate eligibility in resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matching assertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.
B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare publicSharing: when a record transitions to ineligible, stamp revoked_at on its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.
C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none: validateCrossReferences in @objectstack/spec refuses a metadata app any hook naming sys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.
Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.
⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.
Where this came from
hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.
Found while implementing objectstack-ai/hotcrm#1104 (declaring
publicSharingon a knowledge-article object that holds a mix of public and internal records). Not a regression in objectstack#7861 — that card fixed exactly what it said it would, and the fix is measured working. This is the adjacent half it did not cover.What was measured
Read on the installed
@objectstack/plugin-sharing@17.1.0,dist/index.js:ShareLinkService.createLink()callsassertEligible(eligibility, exists[0], schema, object)before writing thesys_share_linkrow. Verified behaviourally: a draft and an internal-audience record are refused withRECORD_NOT_ELIGIBLE/ 422 and no row is written. This is objectstack#7861 working as shipped.ShareLinkService.resolveToken()does not evaluate the predicate. It checksrevoked_at,expires_at, thesigned_in/emailaudience gates, the password, andrecordStillExists(object, recordId)— existence only, under the system context.getPolicy()is called there solely to collectredactFields.So the eligibility policy is a mint-time gate, not a serving-time one.
Why that matters
The state the predicate reads is mutable, and it is exactly the state an editor changes:
published+public→ an author mints apubliclink → the link is handed out;audiencetointernal(orstatusback todraft);The remedy today is to revoke every link on the record by hand, which requires knowing they exist. Nothing in the declaration hints that re-classification is not enough — the block reads as a standing policy about which records may be reached anonymously, and for step 4 it is not one.
This is the same fail-open direction objectstack#7861 argued against, one step later in the lifecycle. It also sits oddly beside the neighbouring behaviour:
recordStillExistsis deliberately fail-closed (an unanswered probe denies, per the comment citing #5190), so a deleted record stops being served immediately while a reclassified one does not.Options
A. Re-evaluate eligibility in
resolveToken(). The record is already fetched by the resolve route, so the marginal cost is one CEL evaluation per redemption. Fail-closed on an unevaluable predicate, matchingassertEligible. Downside: a per-request evaluation on a public, unauthenticated path, and a policy edit silently kills links that were legitimately minted — which is arguably correct, but is a behaviour change for existing deployments.B. Revoke on ineligibility at write time. A platform-side hook on records of objects that declare
publicSharing: when a record transitions to ineligible, stamprevoked_aton its links. Keeps redemption cheap and leaves an audit trail of why the link died. More machinery, and it needs the eligibility predicate evaluated on the post-write record.C. Document it as intended and give the app a supported way to react. If a token is meant to be a durable capability granted at a point in time, say so at the declaration site — but then apps need a reachable seam to revoke on their own policy, and today they have none:
validateCrossReferencesin@objectstack/specrefuses a metadata app any hook namingsys_share_link, with no wildcard escape, which is what forced the app-side half of hotcrm#1104 to stop at the mint gate.Recommendation: A, with B as the follow-on if per-redemption evaluation measures badly. The declaration reads as a policy about anonymous reachability, and the least surprising thing a policy can do is hold. A also needs no new metadata and no new hook surface. Whatever is chosen, C's documentation half is worth doing regardless — the current behaviour is not stated anywhere near the key.
⛔ Not asking for the wider "permit hooks on platform objects an app declares a dependency on" capability here. That was raised and set aside in hotcrm#601, and it should be argued on its own motivation rather than as this card's residue.
Where this came from
hotcrm#1104 / objectstack-ai/hotcrm#1400. That PR does not compensate for this app-side — consumer-side compensation for a producer that cannot express the constraint is the shape that lane refuses — and records the limitation in the object's own note and in the PR body instead.