Skip to content

outlets: every outlet that renders a text field into HTML escapes it by context (mail templates by default, HTML exports, integration payloads), pinned by an outlet list; sys_user.name maxLength held at the write seam (ruling 3C, #25040) #25061

Closed

Description

@objectstack-fleet

Filing gate ③: a task the maintainer set. This carries out ruling 3 C on #25040 (batch #381 item 3, 「其他同意」, 6308462370). Split by triage per that ruling's state line. ⛔ Not a claim.

Disclosure: the #9387 ruling (5323310017) applies. ⛔ No probe string or payload goes on this card, its PR or any comment.

Reader (who acts): the domain:services seat (plugin-email and the outlet owners), in one PR or a short serial set.

The rule (from the ruling)

Done when

  • The outlet list is enumerated from the code (mail templates, HTML export, integration payload builders), and each entry escapes by context, with a pin per entry and one pin on the list itself.
  • sys_user.name's length reading is recorded, and the seam enforces it if it did not.
  • Pins are named by the rule and ablation-verified.

Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 27 · 2026-10-21T06:20Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-25061-outlet-context-escaping
    Worktree: objectstack-issue-25061
    Domain: domain:services
    Seat: domain:services#1
    File surface:

    • packages/plugins/plugin-email/src/template-engine.ts and its render path: escaping by context by default;
    • the other outlets that render a text field into HTML (HTML exports, integration payload builders), each as enumerated from the code. An outlet in another lane is declared cross-lane by the seat before it is touched;
    • the sys_user.name maxLength reading, in platform-objects' sys-user.object.ts, which belongs to the engine lane and is declared before any edit;
    • NEW tests, including one pin on the outlet list itself;
    • a new .changeset/25061-*.md.

    ⛔ No server-side stripping or refusal of markup in text fields (ruling 3C). ⛔ packages/spec/**. ⛔ packages/objectql/**: if the write-seam length rule needs the engine, report a fork. (Stop on breach; explain in the report.)
    Container & model: M, mode:subagent, model: default (opus), quoting the --tier output "no path-derived mandate … floor sonnet · default opus · ceiling fable"
    Clause-②: no
    Responsibility: a text field rendered into HTML at an outlet is not escaped by context everywhere | the maintainer's ruling 3C on #25040 (6308462370): escape by context at every outlet, pin the outlet list, no stripping at the write | every recipient of a mail or an export carrying a user-entered text field
    Thread-read: none
    Serial constraints cleared: No open PR touches plugin-email's template engine or sys-user.object.ts; every open PR's files were read at this claim, and the Version Packages PR #24876 is not a work PR. This seat's #25044 edits security-plugin.ts only.

    The route is the maintainer's ruling 3C (6308462370, batch #381 item 3). ⛔ The #9387 disclosure ruling (5323310017) applies: no probe string or payload goes on any public surface. If the default escaping changes what an existing authored template renders, the declaration moves to the narrowing arm by claim amendment.

    Readings taken on origin/main 4bf079c693.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25061,
      "status": "done",
      "branch": "claude/issue-25061-outlet-context-escaping",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25076",
      "session": "session_011TdZ8GjixNZFLz1xJD9zzK — this run's harness-stamped id (a subagent, so the parent's; the fleet relay derived the same id from the container)",
      "premise_still_valid": true,
      "summary": "I enumerated the outlets from the code: an AST census of every domain:services package, with the full table in the PR body. The only outlet that did not escape by context was service-messaging's renderNotification. For an html or mjml sys_notification_template body it placed payload values into the markup raw, and the email channel sends that string as the html part. It now HTML-escapes every hole in that face, while the subject and the markdown/text faces stay verbatim. Nothing is stripped or refused at the write. The plugin-email engine already escaped by default; its one raw form, the triple brace, is used by the built-ins only for platform-minted URLs in href, and that placement is now pinned. The approval action pages already escaped. There is no server-side HTML export, and no integration payload builder emits HTML. Each of the three packages holding an outlet (plugin-email, service-messaging, plugin-approvals) has a new pin file with one pin on its outlet list (a source census) plus per-entry pins on the real render paths. sys_user.name maxLength 255 is enforced at the write seam, measured on a real engine, so no edit was owed in platform-objects or objectql.",
      "tests": "Final head 1960b459bb, under the lock. service-messaging test exit 0 (Test Files 55/55, Tests 610/610) and typecheck exit 0, with the new pin file in the tsc program (--listFiles count 1). plugin-email test exit 0 (36/36, 643/643) and typecheck exit 0 ('check:test-typecheck: OK — 0 file(s) / 0 error(s)'), at 73f7bf8fe5. plugin-approvals test exit 0 (94/94, 1213/1213) and typecheck exit 0 (test-typecheck ledger unchanged), at 73f7bf8fe5. The only later commit edits service-messaging's pin file alone, and that package re-ran at 1960b459bb. Ablation: 7 legs, one per pinned behaviour, through scripts/ablation-replace.mjs wrap mode. Each landing was proven by on-disk anchor and replacement counts plus a blob change. Each restore was git checkout HEAD -- PATH, proven by restored blob == HEAD blob and an empty git diff HEAD, with git status porcelain empty after every leg. No rebuild was needed: every pin imports its subject from src. Readings: A messaging html face verbatim gave 3/8 red (html, mjml, channel path), re-run at 1960b459bb with the same 3/8. B unlisted messaging file gains an html-named export gave 1/8 red (list pin), re-run with the same 1/8. C plugin-email renderTemplate verbatim gave 32/66 red. D en-US default footer hole triple-braced gave 10/66 red (5 en-US templates x raw-placement and escape). E unlisted plugin-email file gave 1/66 red (list pin). F approval summary rows unescaped gave 2/5 red. G unlisted plugin-approvals file gave 1/5 red (list pin). The first attempts at B, E and G were no-ops: each replacement contained its anchor, so ablation-replace refused the leg as not landed and restored before any test ran. They were re-run with non-overlapping replacements (B2, E2, G2), and those readings are the ones above. sys_user.name was measured with an uncommitted scratch test on a real ObjectQL plus SqlDriver in-memory sqlite, under system context: insert at 255 characters was accepted (control); insert at 256 was refused VALIDATION_FAILED with max_length {maxLength 255, actual 256}; an update to 256 was refused and the stored value was unchanged.",
      "gates": "Local subset. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 commands. All 68 ran under the lock, each exit captured before any pipe. First pass at 73f7bf8fe5: 65 exited 0. check:engine-double-contract exited 1, because the first draft added a new findOne engine double; the pin now uses a stored-row store subclass, and the gate is exit 0 at 1960b459bb. check:i18n and check:dual-build-cjs-loads exited 3 (prerequisite not met); after a full build both are exit 0 at 1960b459bb. Ratchet families re-run at 1960b459bb, all exit 0: engine-double-contract, cross-package-test-inputs, test-source-alias, type-check-coverage, type-check-debt, objectql-double-limit, nul-bytes. --ran with a 'cmd :: exit N' record: '68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN' (a derived zero). CI: PR check-runs at head 1960b459bb were read once right after the create: 24 runs. 'No other open PR may claim the same issue' was success, the two opt-in jobs were skipped, and the rest were in_progress, including Lint & Repo Gates lanes, Type Check lanes, Check Changeset and Governed Surface Queue Guard. Test Core and Build Core had not appeared yet. All NOT MEASURED, reason: not converged at report time. The repo-wide lint and test:repo are CI's and were not run locally.",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm on the claim; open question 1 asks the seat whether to amend to one",
      "mcp_calls": "0",
      "api_writes": "2 — both REST, through the fleet relay (repository_dispatch, run as objectstack-fleet[bot]): (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/25076/assignees [os-justin]; (2) this os-dev-report comment = POST /repos/objectstack-ai/objectstack/issues/25061/comments. The relay's own POST /repos/objectstack-ai/objectstack/dispatches carried each stroke. Not REST: git push to claude/issue-25061-outlet-context-escaping 5 times (the empty-branch probe, the fix commit, the pin commit, the changeset commit together with the main merge, and the channel-pin fix), each a fast-forward with no force.",
      "open_questions": [
        {
          "question": "Clause-②: is the html-face escaping a narrowing? It changes what an existing html or mjml sys_notification_template row renders when a hole's value carries an ampersand, an angle bracket or a quote. That includes a row that relied on a payload value rendering as markup. Measured reach at 73f7bf8fe5: 0 authored html or mjml notification-template rows in this tree (seeds, examples, 0 rows in the dogfood fixtures fixture-18.0 and fixture-18.0.0, no format html or mjml outside packages/spec); 0 of the 3 messaging producers (approvals notify, the audit mention writer, the automation notify node) put pre-rendered HTML into a payload; rows stored in deployments are not measurable from here.",
          "options": [
            "A keep Clause-②: no, a patch changeset (as filed): no accepted key, value or write is refused; only the output encoding of one face changes, and in-tree reach is zero",
            "B amend to no (narrowing): a `!` title, a BREAKING changeset with its ADR-0087 disposition and FROM-TO line (the 'What to do' line already in the changeset), and the full dogfood battery on the final head"
          ],
          "recommendation": "B, per the dispatch's own trigger: an existing authored html row that put a markup-bearing value into its body renders differently after this change, even though the in-tree count is zero. Actual business need: the escaping serves a real path (a display name or a comment preview into notification mail), but no measured author relies on raw injection, so the rendered difference is theoretical in this tree. Long-term soundness: declaring it as a narrowing is the honest record of an output change in a shipped package. Keeping AI from writing wrong code: the BREAKING entry ships the migration line where an upgrading agent greps for it. Startup focus: no new key, gate or capability either way; B costs one dogfood run and a changeset rewrite. If the seat reads 'renders differently' as output encoding rather than a narrowing of an accepted set, A is the cheaper and also defensible reading."
        },
        {
          "question": "Radius of the pin on the outlet list. Each package that holds an outlet carries its own source census, so a new outlet inside plugin-email, service-messaging or plugin-approvals cannot land unlisted. A new outlet in a package with no list (another services package, a new one) is not seen.",
          "options": [
            "A per-package lists, as landed: no cross-package input, no turbo.json or scripts/ edit, stays inside the claimed file surface",
            "B one lane-wide census test: needs a declaration in scripts/cross-package-test-inputs.mjs and a turbo.json test-input entry (outside this claim's surface; the devx/spec lanes), and runs on every PR touching its globs",
            "C a repo-level census in an existing repo vitest project (core, rest or runtime: the engine or cli lanes)"
          ],
          "recommendation": "A. Actual business need: today's outlets are all in three packages, and each is now listed and pinned. Long-term soundness: package-local scans keep the test-input graph honest without new declarations. Keeping AI from writing wrong code: a new HTML-touching file in any outlet package is red until classified. Startup focus: B and C add a wide-radius check, which leans toward a new gate, whose default is no. Revisit B only if an outlet appears in a fourth package."
        },
        {
          "question": "Out-of-lane HTML builders, named for the seat to declare. Not touched.",
          "options": [
            "one out-of-lane HTML builder (outside ruling 3C's text-field scope): named to the seat privately; reach not measured",
            "packages/cli/src/commands/serve.ts (cli lane): the host-not-found page escapes its one value. No action",
            "packages/plugins/plugin-hono-server/src/adapter.ts (cli lane): carries an already-rendered page. No action"
          ],
          "recommendation": "The seat declares the privately named builder cross-lane. It is outside ruling 3C's text-field scope, so it was not filed from here."
        }
      ],
      "out_of_scope_findings": [
        "class: none — noted, not filed · reach: not measured · one out-of-lane HTML builder outside ruling 3C's text-field scope; detail held privately by the seat · carrier: the domain:services seat",
        "class: none — noted, not filed · reach: not measured · one further output context outside HTML (another lane); detail held privately by the seat · carrier: the domain:services seat",
        "carrier: open question 2 · noted, not filed: the outlet-list pin's radius is per package (three packages)"
      ]
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim amendment · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-21T07:18Z · amends claim 6309505551.
    Clause-②: no (narrowing)
    The declaration moves from no to no (narrowing), the trigger the dispatch set: the html/mjml face of service-messaging's notification renderer now escapes every hole. So an existing authored html or mjml sys_notification_template row whose body relied on a payload value rendering as markup renders differently after this change. In-tree reach is zero (no such row, and no producer that pre-renders HTML), and rows stored in deployments cannot be measured from here. The change ships as a BREAKING minor under the amended 「甲」 (6303865025), with a ! title, its ADR-0087 disposition and the full dogfood battery on the final head. It owes a contract-tier review before the queue.


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 25061,
      "round": "patch round 1 (seat ruling: open question 1 = B, question 2 = A, question 3 = the seat's)",
      "status": "done",
      "branch": "claude/issue-25061-outlet-context-escaping",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/25076",
      "head": "7aeddce6f3626efe1fefaaa213b52685bea2ff24",
      "session": "session_011TdZ8GjixNZFLz1xJD9zzK",
      "summary": "The changeset is rewritten in the BREAKING shape of the #24813 changeset (git show 4cef6e2280): front-matter '@objectstack/service-messaging': minor; headline fix(service-messaging)!: with the same sentence as before; the Clause-② no (narrowing) line; an adr-0087 not-required (no-migration-prescription) disposition comment with its category reasoning; a **BREAKING** banner; FROM / TO / Unchanged / What-to-do bullets. No other changeset exists for plugin-email or plugin-approvals, whose diffs are test files only. No code moved in this round. origin/main (2b1382a5b3) moved only objectql, organizations and plugin-security since the merge base 688052e147, none near this diff, so there was no merge.",
      "changeset_diff_note": "The diff below is verbatim, except that the HTML comment delimiters around the adr-0087 disposition are spelled [HTML-COMMENT-OPEN] / [HTML-COMMENT-CLOSE], because GitHub strips that shape from comments. The committed file carries the real delimiters.",
      "changeset_diff": "diff --git a/.changeset/25061-notification-template-html-escape.md b/.changeset/25061-notification-template-html-escape.md\nindex fd7c367033..c771718922 100644\n--- a/.changeset/25061-notification-template-html-escape.md\n+++ b/.changeset/25061-notification-template-html-escape.md\n@@ -1,14 +1,18 @@\n ---\n-'@objectstack/service-messaging': patch\n+'@objectstack/service-messaging': minor\n ---\n \n-fix(service-messaging): an html or mjml `sys_notification_template` body escapes every hole's value for markup\n+fix(service-messaging)!: an html or mjml `sys_notification_template` body escapes every hole's value for markup\n \n The email channel renders a notification template row whose `format` is `html` or `mjml` into the mail's html part. Its `{{ payload.x }}` holes placed each value into that markup as stored, so a text field carrying markup characters — a person's display name, a record title, a comment — was read as markup by the recipient's mail client. Each hole's value in an html or mjml body is now HTML-escaped, in element text and quoted attributes alike, and the template's own markup renders as written. Stored text is never stripped or refused: a display name may contain any character, and the mail client shows it as typed.\n \n-The subject and a `markdown` or `text` body are plain text and keep rendering values verbatim. The email-template path (`sys_email_template`, named in a notify step's `template`) already escaped its html body by default and is unchanged.\n+Clause-②: no (narrowing)\n \n-Clause-②: no — no key, value or write that was accepted is refused; only how the html face encodes a hole's value changes.\n+[HTML-COMMENT-OPEN] adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. An authored template body's own markup renders exactly as before; what changes is how the renderer encodes a hole's VALUE in the html and mjml faces. The remedy is an author's choice — put the markup in the template body instead of a payload value — never a rewrite of a stored row or a package source, and stored `sys_notification_template` rows are not touched. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). [HTML-COMMENT-CLOSE]\n \n-- **What changes.** In an html or mjml body, a value containing an ampersand, an angle bracket or a quote now arrives as the matching HTML entity, which a mail client displays as the original character.\n-- **What to do.** A template that relied on a payload value rendering as markup moves that markup into the template body, or into an email template, whose triple-braced hole is the one unescaped form.\n+**BREAKING** (what an html or mjml notification template renders for a markup-bearing value narrows), shipped as `minor` under the launch-window convention for breaking changes.\n+\n+- **FROM:** in an html or mjml `sys_notification_template` body, a `{{ payload.x }}` hole rendered its value verbatim, so a value carrying markup became markup in the sent html part.\n+- **TO:** the same hole renders the value HTML-escaped: an ampersand, an angle bracket or a quote arrives as the matching HTML entity, which a mail client displays as the original character.\n+- **Unchanged:** the subject and a `markdown` or `text` body are plain text and keep rendering values verbatim. A value without markup characters renders byte-identically. The email-template path (`sys_email_template`, named in a notify step's `template`) already escaped its html body by default and is unchanged, and no stored row is rewritten.\n+- **What to do:** a template that relied on a payload value rendering as markup moves that markup into the template body, or into an email template, whose triple-braced hole is the one unescaped form.\n",
      "pr_title": "fix(service-messaging)!: an html or mjml notification template escapes every hole for markup, and each outlet package pins its outlet list (ruling 3C)",
      "pr_body_append": [
        "Patch round 1: the changeset now ships as a BREAKING minor (`fix(service-messaging)!`, `Clause-②: no (narrowing)`, ADR-0087 `not-required (no-migration-prescription)`), head `7aeddce6f3`.",
        "Full dogfood battery on head `7aeddce6f3`: VERDICT command-exit 0, Test Files 286 passed / 3 skipped (289), Tests 2387 passed / 18 skipped (2405), 0 failed."
      ],
      "pr_body_edit_owed": "Besides the append: body line 2 still reads `Clause-②: no`. Check Changeset reads the declaration from the body, so that line has to be REPLACED by `Clause-②: no (narrowing)`, not supplemented by a second line. The seat writes the body; I did not PATCH it.",
      "dogfood_battery": "7aeddce6f3 · os-verify-lock: VERDICT command-exit 0 (held 762s, shared box) · closure: pnpm turbo run build --filter=@objectstack/dogfood --concurrency=2, exit 0 (66 of 66 cached) · pnpm --filter @objectstack/dogfood test, no OS_TEST_SHARD and no path, exit 0 · Test Files 286 passed | 3 skipped (289) · Tests 2387 passed | 18 skipped (2405) · failures: none",
      "gates": "Both at 7aeddce6f3, base origin/main: node scripts/check-changeset-no-major.mjs --base origin/main exit 0 ('This diff introduces no major bump'; its level axis says NOT APPLICABLE locally, because there is no pull_request payload to read, so CI's Check Changeset is the reading for the body's declaration). node scripts/check-adr-0087-registration.mjs --base origin/main exit 0 ('1 declared-breaking changeset(s), each carrying an ADR-0087 disposition', classified [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)).",
      "api_writes": "1 — this comment: POST /repos/objectstack-ai/objectstack/issues/25061/comments through the fleet relay (repository_dispatch, run as objectstack-fleet[bot]). Not REST: one git push (1960b459bb..7aeddce6f3, fast-forward).",
      "mcp_calls": "0"
    }

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat rulings on PR #25076's open questions · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-21T07:51Z


    Generated by Claude Code

  6. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #25076 comment 6311058652 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-21T07:59Z


    Generated by Claude Code

  7. objectstack-fleet commented on Oct 21, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-21T08:22Z · ⛔ The #9387 disclosure rule applies.

    reading result
    PR #25076 timeline added_to_merge_queue 2026-10-21T08:00Z → merged 2026-10-21T08:20Z
    origin/main by content squash a39389eb51 (one parent, f46ef4d70e), 5 files. template-renderer.ts carries the html-face encoder, and three packages carry text-field-html-outlets.pin.test.ts
    closing references the PR body closes #25061 only

    State change in this stroke: pm:dispatched off and the assignee cleared. The card closed completed on the merge.

    What changes: an html or mjml sys_notification_template body escapes every hole's value for markup, in element text and quoted attribute values. The subject and a markdown or text body render as before. Nothing stored is stripped or refused (ruling 3C, 6308462370). Each package that holds an outlet pins its outlet list. This is a BREAKING minor (contract review 6310965376, ACCEPT 6311058652).

    Follow-ups, all abstract and for triage: #25077 and #25078 (another lane's outputs), and #25082 (the two mail template renderers).


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:apiThe API a customer can call, and integrations — REST, connectors, webhooks, jobsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3security

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions