fix(#4509): three auth-gate disconnects close — email_template bridges, job and validation close their doors - #4558
Merged
Conversation
…ail_template (#4509) Authoring an `email_template` was a silent no-op. `sendTemplate` resolves `(name, locale)` against sys_email_template ROWS, whose only writers were the built-in auth templates plus a code-constructed `EmailServicePluginOptions. templates` that no bootstrapper passed — while every door an author can use (stack `emailTemplates:`, `*.email-template.ts`, Studio, PUT /meta) parked items in a metadata store nothing read back. An admin could "fix" the password-reset mail in Studio and users kept receiving the built-in copy: ADR-0078 false compliance on AUTH mail. Webhook #3461's shape, closed the same way (ADR-0049 enforce-or-remove, route: enforce). `bootstrapDeclaredEmailTemplates` materializes declared items at boot, validating each through EmailTemplateDefinitionSchema.parse() and projecting with `mapTemplateToRow` — the SAME mapping the built-in seeder uses, extracted and shared so the two doors cannot drift. Because email_template is allowRuntimeCreate:true (unlike webhook), the plugin also subscribes to metadata changes and re-materializes the changed item, so a Studio save takes effect without a restart; a withdrawn template deactivates its rows rather than losing them. Three breaks sat on this path, and closing any two would still have shipped a template that never sent: - objectql never registered a manifest's `emailTemplates:` into the registry at all — the key was missing from the generic ingestion list, so the bridge's own source was empty. - Built-in seeds left `managed_by` at the column's 'admin' default, masquerading as admin-authored; since the bridge refuses to overwrite admin rows, a built-in would permanently outrank the template an app declared. Built-ins now stamp 'platform'. - Nothing materialized declared metadata into rows. Seed-not-clobber mirrors sys_webhook (#3489): sys_email_template gains managed_by/customized, declared rows re-seed as 'package', and an admin-created or admin-edited row is never overwritten. Separate axis from `is_system`, which keeps its meaning for built-ins. Ledger email_template flips 13 dead → fully live, with an ADR-0054 proof bound on `subject`: it boots a real stack, authors a template overriding a built-in auth template, and asserts the AUTHORED wording reaches the transport. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
…fact (#4509) A `job` created at runtime could never be scheduled. `JobSchema.handler` names a function in the compiled bundle's function table — the schema says so, and the scheduler is built that way: AppPlugin sources jobs from `bundle.jobs` alone and resolves handlers through `collectBundleFunctions(bundle)`. Yet the type was registered allowRuntimeCreate:true (and allowOrgOverride:true), so a job authored in Studio or via PUT /meta parsed, saved, reported success, and never ran. Unlike the sibling disconnects in this batch, this one cannot be bridged: the runtime writer does not have the bundle and cannot name a function inside it. The missing piece is a handler-binding design, not an ingestion path. Per ADR-0049 enforce-or-remove the honest move is to close the door — both flags now false, with a rationale block modeled on the `agent` entry stating what re-opening would require. `job` stays a first-class authorable type: `*.job.ts` and `defineStack({ jobs })` are the supported doors and are fully enforced, so the kind still passes the ADR-0088 admission test and stays registered. Also restamps job.json's evidence lines, which pointed at app-plugin.ts:767-791 and had drifted ~25 lines — the gate resolves paths, not line numbers, which is exactly the rot `verifiedAt` exists to catch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
…venance columns (#4509) Follow-up to the materializer bridge: `managed_by` / `customized` on sys_email_template are new translatable field labels, so the nine locale bundles had drifted from the schema. `node scripts/check-i18n-bundles.mjs --write` (merge mode — no existing translation overwritten). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
…DR-0088) A validation rule authored as its own artifact bound to nothing and gated no write. ValidationRuleSchema carries no object-binding key — no `object`, no `objectName` — and all six variants are strictObject, so an author could not supply one either. No merge step existed; the only code expecting such a key was a reference-tracker row scanning a field the schema would have stripped. Meanwhile the engine evaluates exactly one shape: the object's own `validations[]`, on insert and on every matched update row. So a rule created through the standalone door (*.validation.ts, or Studio's Validations list) parsed, saved, reported success, and intercepted nothing — including a state_machine rule, which ADR-0020 routes through this same vocabulary. An author could believe they had locked down record state transitions and have changed nothing. Under ADR-0088 the kind fails the admission test on its first clause: a rule has no independent lifecycle, because it only means something against an object. And unlike the sibling disconnects in this batch it could not be bridged into one — the shape has nowhere to name its object. Enforce-or-remove picks ENFORCE where the feature exists and only wiring is missing (email_template), REMOVE where the shape cannot carry the feature. The rule VOCABULARY is untouched: ValidationRuleSchema and all six variants are unchanged and fully live, and the engine's evaluation path is not modified. The ledger keeps governing the schema through the gate's SPEC_ONLY_SCHEMAS override (alongside webhook and query) — an ungoverned live schema is how the next drift would hide. Removed: registry entry (with its file patterns), MetadataTypeSchema member, metadata-core lockstep enum member, schema-map entry, create seed, Studio nav item + hand-crafted form + its four locale strings, and the dangling reference-tracker row. Stored standalone rows are left alone — never evaluated, so nothing changes behaviorally. Also restamps validation.json's engine evidence lines (drifted ~220 lines) and adds the ADR-0088 addendum recording the retirement. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
app.json's type note carried the one gap the AppSchema navigation walk found by hand: an `action` item rendered and gated like any other while its click went to an `onAction` prop no shipped shell passed. objectui @e8bec83 wires it — the sidebar resolves the action name against metadata and dispatches through the console action runtime, and a shell that still passes no handler now hides action items instead of rendering them dead. Re-pins the objectui evidence (the old pin @940ba24 predates the fix) and dates the row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 7 package(s): 111 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
CI's slot-lookup ratchet caught the bridge's `ctx.getService('metadata')`:
email-plugin.ts is grandfathered for its three existing erasures, and a new
untyped lookup pushed it to four. Pass the slot's contract type
(`IMetadataService`) as #4251 asks — which also gives the subscribe/get calls a
real type instead of `any`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu
os-zhuang
marked this pull request as ready for review
August 2, 2026 08:36
os-zhuang
enabled auto-merge
August 2, 2026 08:36
This was referenced Aug 2, 2026
akarma-synetal
pushed a commit
to akarma-synetal/framework
that referenced
this pull request
Aug 3, 2026
…d connect doc.tags (objectstack-ai#4509) (objectstack-ai#4664) Closes the "顺带的三个小清理候选" section of objectstack-ai#4509 — the part left over after objectstack-ai#4558 landed the four structural disconnects. What groups the five retirements is not the type they sit on but WHY they had to go out in a major rather than after a deprecation cycle: four of the five carry schema DEFAULTS, and a default materialises at parse time, so the liveness advisory lint cannot tell a value the author wrote from one the schema supplied. Marking them would have warned on every mapping and every selector in existence — which is why the ledger recorded `_authorWarnSkipped` instead of `authorWarn`. For a key in that state, removal is not the escalation after a warning; it is the only channel that ever reaches the author. With spec at 17.0.0-rc.1 and pre-mode still open, that channel closes at `changeset pre exit` and reopens in v18. Removed (strict deletion + `guidance` prescriptions, ledger rows deleted): mapping.extractQuery promised an export path no exporter implements mapping.errorPolicy error handling belongs to the import REQUEST mapping.batchSize the write path sizes its own batches app.contextSelectors[].includeAll app.contextSelectors[].placement `includeAll` is the one worth reading twice: not unread but deliberately DISOBEYED, and for a security reason. Context selectors are mandatory-scope, so an "All" row would clear a scope that exists to be scoped — on Studio's package selector that means listing the platform's own system/cloud kernel packages to a developer who scoped to their own package. STUDIO_APP shipped authoring `includeAll: true` against a renderer that ignored it; that authoring site goes with the key here. `batchSize` deliberately offers no rename. bulkActionDef/connector/sync/offline /seed-loader/NoSQL-cursor `batchSize` are all live and enforced, but each is a different key on a different type sizing its own path. "Removed" plus a familiar name one line away is exactly how a dead setting gets laundered into a live-looking one — the same trap datasource.retryPolicy had to defuse against hook/job retryPolicy (which spell the delay `backoffMs`) in objectstack-ai#4583. A pin test asserts the message names them as DIFFERENT keys. Retired ALIAS spellings (query, onError, errorHandling, errorMode, batch, chunkSize, skipErrors, showall, location) route to the same prescriptions rather than suggesting a rename onto a key that is also gone. Connected, not removed — doc.tags: `BookGroup.include` has always accepted `{ tag }`, and it could never match a single doc in any stack. Not because the matcher was missing: `matchesInclude` compares `doc.tags`, the book route already forwards `tags: d.tags`, and `ResolverDoc` already declared `tags?: string[]` annotated "(P3d; absent today)". The gap was one line at the AUTHORING end — DocSchema is strict and had no `tags` key, so writing one was a parse error and every doc reached the resolver with tags undefined. ADR-0049 says enforcement wins when the feature exists; removing the variant would also have discarded working matcher code and left authors a bare union error carrying no prescription. ADR-0087: new conversion `mapping-inert-keys-removed` (scoped to the `mappings` collection deliberately — a stack-wide strip would delete an enforced batchSize from connector/sync/bulk-action/offline) plus an extension of `app-dead-authoring-keys-removed` to drill the contextSelectors array; both wired into the protocol-17 D3 chain step. `allValue` was re-verified as its ledger note required: still live (the shell reads it for auto-selection and query-param defaulting), but its describe() no longer calls it "the value emitted when All is selected" — an event that cannot occur and never could. Incidental, from confirming the area gates while working the selector keys: filterAppForUser walks only the top-level `navigation` tree and never reads `item.areas`, so area-level visible/requiredPermissions are FAIL-OPEN, not merely unread. Recorded accurately in the ledger and filed as objectstack-ai#4651 rather than fixed here — inventing an authorization mechanism inside a retirement PR is exactly what objectstack-ai#4583 declined to do for managed read-only. mapping joins datasource at zero dead keys. Claude-Session: https://claude.ai/code/session_01E5CYr5SDwe85gH2Jr5KSgu Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes items 1–3 of #4509. Item 4 (nav
actiondispatch) is objectstack-ai/objectui#3180.Each of these is an authorization gate opening onto empty ground: an author writes metadata, the platform accepts it, and nothing ever reads it. Per ADR-0049 enforce-or-remove, each is resolved — never left standing. The route differs per item, and that is the interesting part: enforce where the feature exists and only the wiring is missing, remove where the shape cannot carry the feature at all.
1.
email_template→ bridge built (the security-shaped one)sendTemplateresolves(name, locale)againstsys_email_templaterows, whose only writers were the built-in auth templates plus a code-constructedEmailServicePluginOptions.templatesthat no bootstrapper ever passed. Meanwhile every door an author can use — a stack'semailTemplates:, an*.email-template.tsfile, Studio's metadata-admin list,PUT /meta— parked items in a metadata store nothing read back. An admin could "fix" the password-reset email in Studio, get a success toast, and watch users keep receiving the built-in copy: ADR-0078 false compliance on authentication mail.bootstrapDeclaredEmailTemplatesmaterializes declared templates intosys_email_templateat boot, validating each throughEmailTemplateDefinitionSchema.parse()and projecting withmapTemplateToRow— the same mapping the built-in seeder uses, extracted and shared so the two doors cannot drift apart. Becauseemail_templateisallowRuntimeCreate: true(unlikewebhook), the plugin also subscribes to metadata changes and re-materializes the changed item, so a Studio save takes effect without a restart; a withdrawn template deactivates its rows rather than losing them.Three breaks sat on this path, not one, and closing any two would still have shipped a template that never sent:
@objectstack/objectqlnever registered a manifest'semailTemplates:into the metadata registry at all — the key was simply missing from the generic ingestion list, so the bridge's own source was empty.managed_byat the column's'admin'default, making platform templates masquerade as admin-authored. Since the bridge refuses to overwrite admin rows, a built-in would have permanently outranked the template an app declared.The first two were found by the ADR-0054 proof, not by the unit tests — which is the argument for having written it.
Seed-not-clobber mirrors
sys_webhook(#3489):sys_email_templategainsmanaged_by/customized, declared rows re-seed aspackage, and an admin-created or admin-edited row is never overwritten. Separate axis fromis_system, which keeps its meaning for built-ins.2.
job→ gate closed (cannot be bridged)A runtime-created
jobcould never be scheduled.JobSchema.handlernames a function in the compiled bundle's function table — the schema says so, andAppPluginsources jobs frombundle.jobsalone, resolving handlers throughcollectBundleFunctions(bundle). The runtime writer does not have the bundle and cannot name a function inside it, so the missing piece is a handler-binding design, not an ingestion path.allowRuntimeCreate: falseandallowOrgOverride: false(the latter goes beyond the issue's literal text — an org-overridden job is unreachable for exactly the same reason). A rationale block modeled on theagententry states what re-opening would require. The kind stays registered: its file loader is genuinely consumed, so it still passes the ADR-0088 admission test, and*.job.ts/defineStack({ jobs })remain fully enforced.3.
validation→ kind retired (ADR-0088)A rule authored as its own artifact bound to nothing.
ValidationRuleSchemacarries noobject/objectNamekey, and all six variants arestrictObject— so an author could not supply one either. No merge step existed; the only code expecting such a key was a reference-tracker row scanning a field the schema would have stripped. The engine evaluates exactly one shape: the object's ownvalidations[].So a rule created through the standalone door saved cleanly, reported success, and intercepted nothing — including a
state_machinerule, which ADR-0020 routes through this same vocabulary. An author could believe they had locked down record state transitions and have changed nothing.The kind fails the ADR-0088 admission test on its first clause: a rule has no independent lifecycle, because it only means something against an object. Removed: registry entry (with its file patterns),
MetadataTypeSchemamember, metadata-core lockstep enum member, schema-map entry, create seed, Studio's Validations nav item + hand-crafted form + its four locale strings, and the dangling reference row.The rule vocabulary is untouched —
ValidationRuleSchemaand all six variants are unchanged and fully live, and the engine's evaluation path is not modified. The ledger keeps governing the schema through the gate'sSPEC_ONLY_SCHEMASoverride (alongsidewebhookandquery), because an ungoverned live schema is how the next drift would hide. Stored standalone rows are left alone: never evaluated, so nothing changes behaviorally.Verification
email-template-materialization, bound onemail_template.subject): boots a real stack, authors a template that overrides a built-in auth template, and asserts the authored wording reaches the transport — both ends of the graph the issue found disconnected.customizedguard fails exactly the seed-not-clobber test.pnpm build+pnpm testgreen (132 tasks; dogfood 440 passed).check:liveness,check:empty-state,check:variant-docs,check:strictness-ledger,check:generated,check:i18n— green.email_templateflips 13 dead → 21 live with the bound proof;jobandvalidationrecord their closures;app.jsonre-pins the objectui evidence. Stale evidence line numbers restamped injob.json(~25 lines drifted) andvalidation.json(~220) — the gate resolves paths, not line numbers, which is the rotverifiedAtexists to catch.Reviewer's attention
job'sallowOrgOverride: falseis broader than the issue asked for; the reasoning is in the rationale block, and it is easy to drop if you disagree.packages/runtime/src/domains/packages.ts'semailTemplatesexport exclusion is comment-only here; whether package-installed templates reach the registry is a separate question from the authoring doors this closes.Generated by Claude Code