feat(spec)!: retire connector.connectionTimeoutMs — carried everywhere, applied nowhere - #19657
Conversation
…rovider-context carry Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
… semantic entry, consumers and pins Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…reference docs Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…tire-connector-connection-timeout
📓 Docs Drift CheckThis PR changes 6 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2b92990ed2f80d167a6def02a1968e5ef0e86ffd && git checkout 2b92990ed2f80d167a6def02a1968e5ef0e86ffd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6eaa0f4a81a0146250f083df2b9d000b75dbec61 1e5a44ab640141319360551b9207e711fee8fb47 && git checkout -B drift-repro 6eaa0f4a81a0146250f083df2b9d000b75dbec61 && git merge --no-ff 1e5a44ab640141319360551b9207e711fee8fb47
node scripts/docs-audit/affected-docs.mjs --json 6eaa0f4a81a0146250f083df2b9d000b75dbec61
|
…eted card number The four #19388 citations this change added do not resolve: probed [deleted] — minted, absent from the board, and the web endpoint 404s. The claim they attributed is unchanged and independently checkable in the tree, so each site now names connector-fetch-policy.ts, where connectorFetchOptions() maps requestTimeoutMs onto resilientFetch's per-attempt timeoutMs, pinned by connector-fetch-policy.test.ts. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
… correct the census note F1 — the key was `.optional().default(30000)`, so a 17.x parse materialized it into every connector. Measured across two builds: the base build emits it for an entry that authored only name/label/type, and the tombstoned build refused that exact object at connectors.0.connectionTimeoutMs. Adopts the ruled acceptRetiredDefaultResidue stage on both carriers. A D2 does not discharge this obligation — the ObjectPermission precedent carries both — because AutomationEngine.registerConnector parses ConnectorSchema for a def a plugin builds in code, where no conversion runs. Nothing is un-retired: z.input stays never, the [RETIRED] row stays, and any other value keeps the refusal. The residue wrapper is a preprocess pipe, so the ADR-0097 refinements move onto its OUT side; dropped-refinements.baseline.json moves the five site paths with them, as the gate required in the same change. F2 — the card's five-writes table was CORRECT at the SHA it cited (0870fb5) and was superseded by b929e0a. It is stale, not false, and the ledger note, the entry and the changeset now state both readings with their trees: thirteen non-test source occurrences over seven files in five packages at origin/main — six reads, four type declarations, three surviving hardcoded writes. N2 — the absence-pin pointer names the file the pin actually lives in. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
FB1 — connector.zod.ts still promised that the base export 'stays a plain object so connector subtypes can still .extend() it'. Both published carriers are z.preprocess pipes since the residue stage, so that is false, and the sibling repo quotes the sentence verbatim in its own code. Measured on the built entry against a plain-object control (WebhookConfigSchema, which keeps all nine): .extend/.omit/.pick/.partial/.merge/.strict/.keyof/.safeExtend are gone from both. .superRefine SURVIVES — it lives on zod's base type — but returns a schema with no read-through shape, so my own new docblock overstated it and is corrected too. The affordance withdrawal is now a FROM to TO row in the changeset with the extend-the-base-and-re-wrap remedy. FB2 — five records asserted a composition this change abolished: the two carriers no longer derive from one another, they are siblings wrapping one private ConnectorBaseSchema. Three of them I authored in the round that fixed the same defect class. Corrected in the two retired-key entries, the two conversion docblocks, the schema docblock, the reachability comment in connector.test.ts and the liveness _note, whose walk mechanism is restated and whose conclusion is re-measured: 30 keys on each carrier, byte-identical key sets, no entry-only and no base-only key. NB5 — the changeset said allowPurge carries both 'because' registerConnector, compressing two different reasons into one. Separated. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…s README Declared widening, one table row. The row asserted in the present tense that DeclarativeConnectorEntrySchema IS ConnectorSchema.superRefine(...) and rested its byte-identical-key-set conclusion on that Zod 4 attachment. This PR falsifies both halves: the two carriers are now siblings wrapping one private ConnectorBaseSchema in the residue stage, and what preserves the walked shape is the pipe's read-through shape, not a superRefine attachment. Mechanism corrected, conclusion kept and re-measured on the built entry (30 keys each, byte-identical, zero entry-only, zero base-only), and the row says which spelling moved and when — the form used on the six sibling sites. This file is hand-written Notes prose by .gitattributes' own split, not a driver- managed artifact, so a hand correction is the right act; regenerating a Note would fabricate a verdict. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…head FB-A — forty words after the sentence round 4 corrected, the row still said 'four retiredKey tombstones'. Two instruments disagreed on the absolute number and agreed on the delta, so I established the scope the sentence means before counting: it enumerates the contents of THIS ledger's dead set, so the population is this file's dead rows that are retiredKey tombstones kept because the key stays in the walked shape. One instrument over both refs reads 5 on origin/main and 6 at head. The six are now named individually rather than totalled, with the double-count that made the old tail drift called out: three of them already sit inside the fieldMappings, triggers and health counts. NB-1 — the rest of the row was stale too (not introduced here; byte-identical on origin/main). The 20/1/53 split and the 53 dead become 29/1/44 and 44, cited to the generated state-counts row. retryConfig (8) leaves the dead list entirely: all eight sub-keys are live since #18975, which is the same measurement this row's own falsification note records. 'The two timeouts' is corrected: requestTimeoutMs is live, connectionTimeoutMs is the tombstone. The decomposition is partitioned so every dead row is counted once and sums to 44. NB-2 — the four PR-authored 'inherits' spellings contradicted this PR's own 'siblings, not parent and child'. Respelled the way connector.zod.ts already does. The pre-existing ones are left for their own round. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…its census stale Found by doing what the round asked — re-reading the WHOLE row against head rather than the named sentences. The closing note asserted two things that are no longer true, and one of them contradicted the correction this same round made forty words earlier: - it recorded retryConfig's 'they are live' claim as falsified, but #18975 made the declared policy execute at the one platform fetch site, so those eight sub-keys are live now and the claim came true after the fact; - its supporting census, 'the word does not occur outside packages/spec at all', is false at this head: git grep over the tree minus packages/spec returns 54 hits over 10 files. Both halves are recorded rather than overwritten — the history of how the type got here is what this row is for — and the census is restated with the command and the tree behind it. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
…d command The census clause printed 54 hits over 10 files while printing a command that returns 67 over 15: the reading was taken with `.changeset` excluded and the exclusion was never written down, and the parenthetical covered neither of the two `content/docs` pages it returns. Print the command that produces the number, pinned to the tree it was taken against, and make the parenthetical account for all fifteen files. Same row: `name` is itself a `ConnectorProviderContext` field, so the "plus `name`" tail double-counted it, while `provider` -- which selects the factory and never reaches the context -- sat outside the "exactly". `loadPackageFile` is host-injected rather than authored. Correct the set. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…on claims
The `_note` in `packages/spec/liveness/connector.json` still carried the
uncorrected form of the key-reach claim after the README row was fixed, and
its tail asserted something the same file's own `actions.key` row already
contradicted. Four measured corrections, all in one sentence:
1. `name` IS a `ConnectorProviderContext` field
(`connector-provider.ts:68`), so "plus `name`" double-counted it;
2. `provider` is read on the AUTHORING door and is not on the interface —
`plugin.ts:1478` gates the desired set on it and `:1533` selects the
factory via `engine.getConnectorProvider(provider)` — so it was left
out of the "exactly";
3. `loadPackageFile` IS a context field (`:117`) that no authored key
reaches — `plugin.ts:1601` injects `createPackageFileLoader(...)` — so
it was over-included;
4. "read by no runtime" is FALSE: `plugin.ts:433`
`findInertDeclaredConnectors` reads `(c.actions?.length ?? 0) > 0` on
every descriptor at boot, which the `actions.key` row already records
as the #2612 inert-descriptor warning. Reaching no provider factory
and being read by nothing are two different claims; only the first
holds of the remainder.
The whole entry-read census is now stated: the materializer reads exactly
`name`, `provider`, `enabled`, `label`, `description`, `icon`, `type`,
`providerConfig`, `auth`, `retryConfig`, `requestTimeoutMs` plus that one
`actions` read, the last nine also being `connectorInstanceSignature`.
README row 942, `authentication` clause: "refused outright by ADR-0097 §3"
is contradicted by all three instruments including the one it cites. The
key is accepted (`connector.zod.ts:893`
`.optional().default({ type: 'none' })`); `:1168` refuses a non-`none`
VALUE and `:1174`'s message prescribes "drop `authentication` (or set
`{ type: 'none' }`)"; ADR-0097 §3 "Credentials are references" rejects
INLINE SECRETS, not the key. Accepted-and-ignored plus a loud refusal of
every other value is the basis of the `planned` verdict the row already
stated.
Same file, `auth` row: "whose other half (`authentication`) is refused"
compressed to the same wrong claim; scoped to "any value but
`{ type: 'none' }`".
README row 942, census sentence: "67 hits" -> "67 matching lines", with the
`git grep -o` reading (77 occurrences) beside it — re-measured at
14fdebd on this checkout, 67 lines / 15 files / 77 occurrences.
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…tire-connector-connection-timeout Conflict in packages/spec/src/migrations/registry.ts, step18.rationale (the hand-written tail, outside the generated markers): main's paragraph for the translation bundle split is kept whole after the shared closing line, and this branch's connector.connectionTimeoutMs paragraph is appended after it. The only byte changed in main's paragraph is its terminator (`.",` becomes `. "`) so the concatenation continues. conversionIds merged as a set without conflict: both ids present. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
…tire-connector-connection-timeout Second sync: main advanced by six commits after the first merge, including the tenancy.organizationField retirement, which also appends to step18.rationale. Conflict in packages/spec/src/migrations/registry.ts, step18.rationale (hand-written, outside the generated markers): the shared closing line and the translation bundle paragraph stay once, the organizationField paragraph is kept whole, and this branch's connector.connectionTimeoutMs paragraph is appended after it. The only byte changed in main's text is the organizationField paragraph's terminator (`.',` becomes `. '`) so the concatenation continues. conversionIds merged as a set without conflict: all three step-18 additions present. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Reviewed and posted 2026-09-23T11:03Z by the at-tier review subagent the ① Derived judgments
② Semver levelCorrect. ③ Boundary flagsDev flags, each answered on the card: Blocking: none.
Implemented-by: VERDICT: PASS |
…ector-ledger note (objectstack-ai#19746) Part of objectstack-ai#19729 ## What this changes Two sentences on `.changeset/18582-connector-analytics-cube-liveness-ledgers.md:9` — the card's **class 1**, the two statements that were **false when written**. One file, one line, `+1 / -1`. No other sentence in that fragment, and no other fragment, is touched. ## DELIBERATE CORRECTION — this is the written confirmation `pr-automation.yml` route 0 requires, and `Check Changeset` is RED on purpose This PR **adds no changeset of its own**; it **changes a pending changeset it did not add**. Route 0's discriminator, run against this PR's merge base: ``` $ git diff --name-status 16d090e HEAD -- '.changeset/*.md' M .changeset/18582-connector-analytics-cube-liveness-ledgers.md ``` Every row is `M`, none is `A` ⇒ route 0. The class is **DELIBERATE CORRECTION, not COLLISION**: this PR did not draw that filename, nothing of its own was overwritten, and the base copy must **not** be restored — restoring it republishes the false sentences. `check-empty-changeset.mjs` reaches the same reading on its own and prints it in the job log. | Route 0 prescribes | Here | |---|---| | ⛔ do **not** apply `skip-changeset` | Not applied, and it must not be: the note corrected below is a release that is still pending, so the label would be a false declaration. | | Write the confirmation on the PR, naming the note and what changed under it | This section. | | Leave `Check Changeset` **RED** | It is red, deliberately. It is not one of the seven required contexts, so it blocks no merge. The red is what puts this decision in front of a person. ⛔ Please do not turn it green, and please do not read it as a failure — every *other* check should be green. | ### The note `.changeset/18582-connector-analytics-cube-liveness-ledgers.md` — `"@objectstack/spec": patch`, **pending**, added by commit `559041d39d`. `changeset version` deletes the fragment and publishes its text verbatim into `packages/spec/CHANGELOG.md`, and the `chore: version packages` PR that performs that is open right now. That is the window this correction is inside. ### What changed under it — old and new, verbatim **(a)** old: > `authentication` is `planned`: refused outright by ADR-0097 §3, never ignored. **(a)** new: > `authentication` is `planned` because the key is accepted and inert rather than refused: the schema declares `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })`, so it parses and the accepted value reaches no consumer, while the objectstack-ai#7990 cross-field rule loudly rejects every non-`none` value and names `auth: { type, credentialRef }` as the mechanism to use instead. ADR-0097 §3 ("Credentials are references") backs that refusal of inline secrets — it does not refuse the key. **(b)** old: > The keys an authored entry can actually reach are the `ConnectorProviderContext` fields plus `name` and `enabled`; **(b)** new: > The keys an authored entry can actually reach are the author-supplied `ConnectorProviderContext` fields plus `provider` and `enabled` — `name` is itself one of those fields, `loadPackageFile` is host-injected rather than authored, and `provider` never reaches the context yet decides on the authoring door whether the entry is materialized at all and which factory does it; ### Why these two are defects in the record and not a dated reading Every instrument below was read at **`559041d39d^` — the parent of the commit that added the fragment** — so nothing that changed afterwards is involved. The card's readings were treated as input and re-derived, not quoted. | The sentence's claim | Instrument at `559041d39d^` | Reading | |---|---|---| | `authentication` is "refused outright" | `packages/spec/src/integration/connector.zod.ts:753` | `authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' })` — the key is accepted, and defaulted. | | same | same file `:970` | `if (entry.authentication && entry.authentication.type !== 'none')` — only a non-`none` **value** raises an issue, and that refusal's own message prescribes "drop `authentication` (or set `{ type: 'none' }`)", which is only sayable if the key is accepted. | | "by ADR-0097 §3" | `docs/adr/0097-declarative-connector-instances.md` §3, titled "Credentials are references" | "Inline secrets in stack metadata are rejected at authoring/publish (lint + schema)." Inline secrets — not the key. | | "never ignored" | `packages/spec/liveness/connector.json`, `props.authentication.note`, seeded by the **same commit** | "the only value an author may write is `{ type: 'none' }` … Not `live`: the accepted value does nothing". The same commit wrote the correct statement in the ledger and the false one in the changeset. | | "plus `name`" | `packages/spec/src/integration/connector-provider.ts:58` | `readonly name: string` is itself a `ConnectorProviderContext` field ⇒ the tail double-counted it. | | `loadPackageFile` included | same file `:77`; `packages/services/service-automation/src/plugin.ts:1546` | On the interface, but the materializer sets it to `createPackageFileLoader(this.options.packageRoot)` — host-injected, reached by no authored key ⇒ over-included. | | `provider` absent | `plugin.ts:1451`, `:1494` | `if (typeof entry.provider !== 'string' ...) continue` gates the desired set and `const provider = entry.provider` then selects the factory, so `provider` is read on the authoring door; it is on no field of `ConnectorProviderContext` ⇒ omitted. | Both replacement sentences are date-neutral: they name no count and no enumeration, so they stay true at the seeding tree and at `origin/main` alike. ### What deliberately did NOT change The rest of line 9 is left byte-for-byte as written, because each of these was **true when written** and has merely been overtaken. A dated record's job is to say what was true when it was made, so overwriting it would falsify history rather than correct a record: - `74 properties: 20 live, 1 planned, 53 dead` — the seeding-time measurement. - `four declared subsystems with no engine — syncConfig, fieldMappings, retryConfig, health` — `retryConfig` was genuinely dead when written. And these carriers are not touched at all, for the same reason: - `.changeset/18614-conversion-registry-retryconfig-liveness-claim.md` - `.changeset/18983-connector-header-rate-limit-remedy.md` - `packages/spec/src/conversions/registry.ts` ## Verification Gate families derived from this worktree, never from the shared checkout: ``` node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands ``` It derived **19** families at commit `752992dd28`. All 19 were run, each exit code captured **before any pipe**, and reconciled: ``` ✓ dispatch-gates --ran: 19 derived famil(ies) accounted for — 19 run, 0 NOT-MEASURED (a DERIVED zero — all 19 recorded an exit code and none of them is 3). ``` **18 of 19 exit 0.** The one non-zero is the expected one: - `node scripts/check-empty-changeset.mjs --base origin/main` — **exit 1, the route-0 red**. Its output names this PR's class as DELIBERATE CORRECTION unprompted and ends "this gate stays red either way, and staying red is what puts the decision in front of a person instead of routing around it." Run in addition, because `dispatch-gates` flagged that its roster lives under `.changeset`, which is where this PR's only path is: - `node scripts/check-changeset-fixed.mjs` — exit 0, "`.changeset/config.json` "fixed" group is in sync with 70 public workspace packages" (a verdict over a real population, not a vacuous green). **Repo-wide `pnpm lint` narrowed to this diff, and the narrowing proven rather than asserted** — all three readings, so the narrowing is a measurement and not a skip: 1. **Population, read from eslint's own config**, not guessed: `isPathIgnored('.changeset/18582-connector-analytics-cube-liveness-ledgers.md')` is `true`; the positive control `isPathIgnored('scripts/check-nul-bytes.mjs')` is `false` on the same call, so the predicate can answer either way. Every `files` glob in `eslint.config.mjs` names TS/JS extensions only, and the config contains zero occurrences of `markdown` or the markdown extension. 2. **File count, read from `--format json`**: one result entry, `errorCount` 0, and its only message is "File ignored because no matching configuration was supplied" — zero rules evaluated. The same command over the control path produces a genuinely linted entry. 3. **Invariance for untouched files**: the one changed path is in no eslint population at all and no markdown processor is configured, so the diff parses nothing and cannot move any untouched file's verdict. Type-aware linting does not enter into it — the file is never handed to a parser. **No package build, test or typecheck is owed**: the diff touches one `.changeset/*.md` file and no package source, so there is no affected-package closure and no package's public surface moves. Control characters: `grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'` over the changed file reports nothing, with the same pattern firing on a seeded control file in the same run. ## Acceptance notes Observations found while verifying, deliberately **not** acted on in this PR: - **Two further carriers of the same class-1 false claims live in in-repo source at `origin/main`**, both written by the same seeding commit `559041d39d`: `packages/spec/liveness/README.md`'s `connector` row (both claims) and `packages/spec/liveness/connector.json`'s `_note` (claim (b) only). **Not filed and not edited** — both are already corrected on the open PR objectstack-ai#19657's head, verified by reading that head directly. Carrier: PR objectstack-ai#19657. - **One input to this work did not survive re-measurement, in a way worth recording**: the corrected wording was described as liftable from `packages/spec/liveness/README.md:942`. At `origin/main` that line still carries the **old, false** wording; the corrected text exists only on PR objectstack-ai#19657's head, which is not merged. The wording used here was derived at source instead, and it agrees with objectstack-ai#19657's. - Source line numbers drift 7–8 lines between the card's citations and `origin/main` (`connector-provider.ts` `:68` vs `:65`; `plugin.ts` `:1478`/`:1533`/`:1601` vs `:1470`/`:1513` and `:1525`/`:1594`). Substance is identical; noted only so a re-measurer does not read the drift as disagreement. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ Co-authored-by: Claude <noreply@anthropic.com>
…tor at authoring time (objectstack-ai#19861) Fixes objectstack-ai#19751 Clause-②: no (narrowing) ## What changes `checkViewFilterRuleValueShape` (the value-shape refinement of `ViewFilterRuleSchema`, `packages/spec/src/ui/view.zod.ts`) now refuses a rule with NO `value` on every operator that takes one. Its scalar arm returned early on `value === undefined` for every operator, so `{ field: 'name', operator: 'icontains' }` parsed green, while the key's published description says every operator outside `in` / `not_in` / `between` and the four unary operators takes a scalar, and the query path refuses the lowered rule with `400 INVALID_FILTER`. - The four unary operators (`is_empty`, `is_not_empty`, `is_null`, `is_not_null`) are answered first and stay valueless, with or without a value. - `in` / `not_in` / `between` keep their own arms, which already refused an absent value. - The key stays `.optional()` on the shape; the coupling lives in the refinement, like the other arms. - The `value` `.describe()` is unchanged (it already declares this contract), so no generated reference page moves. - The code comment above the scalar arm, which named an absent value as a carve-out "the query path itself makes", now says the opposite and why. The docblock's "mirrors the query path" list and its runtime-wording section name the new arm. Refusal text, one issue at the rule's `value` path: > Filter comparand for operator "icontains" on field "name" is undefined. The rule carries no value, and "icontains" compares the field against one — write the value to compare against, or, if the rule means the field has no value, use an operator that takes none ("is_empty" / "is_not_empty" / "is_null" / "is_not_null"), which reads its direction from its name. This is refused at authoring time because the query path refuses it too (400 INVALID_FILTER). The leading sentence is the runtime's undefined-comparand sentence ("Filter comparand at PATH is undefined.") with the location named in the view vocabulary: operator and field, the same substitution the list and range arms already make. A view rule has no `where` path, and the `$` spelling in that path is not one a view author can write. The unary operator names in the tail come from the schema's own `VIEW_FILTER_VALUELESS_OPERATORS`. ## Producer reading (step 1): objectui at the pinned `.objectui-sha` `87af769e9a3ee28ace099fdd653d3ebd79fe82e2` Read with `git show SHA:PATH` from a local clone at that sha, not from a working tree. **Does the console ever save a value-taking rule with no `value`? No.** | writer | file at the pinned sha | what happens to a half-filled row | |---|---|---| | `foldFilterGroupToSpecRules`, the one fold every view-filter writer shares | `packages/app-shell/src/views/viewFilterFold.ts` | a row whose operator takes a value is dropped when `isFilterValueComplete(operator, value)` is false (`if (takesValue && isMissingValue(...)) continue`) | | `isFilterValueComplete` | `packages/components/src/custom/filter-builder.tsx` | false for `value == null` (also `''`, `[]`, a half-filled pair), so an absent value is always incomplete | | `FilterBuilderField` / `FilterBuilderWidget`: the `filter-builder` widget that `view.form.ts` names for `filter` and `page.form.ts` for `filterBy`, plus the per-tab filter editor | `packages/app-shell/src/views/metadata-admin/widgets.tsx` | calls the fold on every change; the runtime `ViewConfigPanel` hosts the same inspector (`ViewConfigPanel.tsx`, `ViewVariantInspector`) | | list toolbar | `packages/app-shell/src/views/ObjectView.tsx` | no automatic write at all (its docblock: "There is deliberately NO persistViewFilter"); explicit saves go through the fold | | drill-down "Save as view", `foldUrlFilterTriplesToSpecRules` | `packages/app-shell/src/views/ObjectDataPage.tsx` | `ViewFilterRuleSchema.safeParse` per rule, refused rules dropped; the URL triples (`drillUrlFilters.ts`, `parseUrlFilterTriples`) skip an empty param and always carry a value | One edge, stated rather than hidden: `handleViewConfigSave` (`ObjectView.tsx`) persists the config draft whole. A view whose STORED body already carries such a rule (hand-authored, or written by another tool) and is re-saved through the panel without its filter being touched now gets the refusal at save. That view already fails every query today (next table). **Does anything drop a valueless row between storage and the query? No.** | layer | file | reading | |---|---|---| | console lowering: `viewFilterRuleToNode`, behind `toFilterNode` / `mergeFilterNodes` (plugin-list `buildEffectiveFilter`, plugin-view `ObjectView`, `ObjectGrid`, `RelatedList`, `LineItemsPanel`) | objectui `packages/core/src/utils/filter-converter.ts` | a rule without `value` lowers to the 2-tuple `[field, operator]` and nothing skips it; its own comment records the runtime throwing `INVALID_FILTER` / 400 for `['name','icontains']` | | REST lookup-picker route: `lowerViewFilterRule` | this repo, `packages/rest/src/view-filter-rule-lowering.ts` | the same 2-tuple; the module forwards and never drops | | query normalizer | this repo, `packages/metadata-protocol/src/protocol.ts` | `isFilterAST`, then `parseFilterAST`, which throws | Measured on this tree's spec source (4112752): `isFilterAST(['and', ['name','equals'], ['status','equals','open']])` is true, and `parseFilterAST` of it throws `INVALID_FILTER` / 400, "Filter comparand at where.$and[0].name is undefined". One valueless rule fails the WHOLE view's query, its good rules included. So no working flow saves or executes this shape, and refusing it at save breaks nothing that works today. ## Today's behaviour for the whole class (step 2) Measured at `origin/main` 4112752 by script. The operator list is `VIEW_FILTER_OPERATORS` read at runtime; the unary set was derived by behaviour from the schema's own scalar arm (an array is refused on every non-list, non-range operator outside the private valueless set). | operators | `ViewFilterRuleSchema`, `value` omitted, before this change | `parseFilterAST([field, op])` | |---|---|---| | `equals`, `not_equals`, `contains`, `not_contains`, `icontains`, `starts_with`, `ends_with`, `greater_than`, `less_than`, `greater_than_or_equal`, `less_than_or_equal`, `before`, `after` (13) | **ACCEPT** | throws `INVALID_FILTER` / 400, "Filter comparand at where.name (or where.name.$op) is undefined" | | `in`, `not_in` | refused by the list arm | throws, "requires an ARRAY of values" | | `between` | refused by the range arm | throws, "requires a [min, max] value array" | | `is_empty`, `is_not_empty`, `is_null`, `is_not_null` | accept | `{ "$null": true }` / `{ "$null": false }` | After this change the 13 are refused. The other rows are unchanged. ## ADR-0087 reading (step 4) - This narrows a published accept set. The repo's rule for that during the launch window is in the header of `scripts/check-changeset-no-major.mjs`: the level does not carry breaking-ness, and "the mandatory information carriers for breaking-ness in the meantime are the **BREAKING** banner the author writes in the changeset body and the ADR-0087 migration-ledger disposition". `scripts/check-adr-0087-registration.mjs` then requires a disposition on the declared-breaking changeset. - The disposition is `registered`, not `not-required`. The author has a hand prescription (write the value, switch to a unary operator, or delete an unfinished row), and `no-migration-prescription` is refused for a body that carries one. None of the other categories fits: the package publishes, no existing entry covers absence, and the surface is a schema, not a runtime interface or a type surface. - Precedents: `view-filter-rule-scalar-operator-array-refused` (the sibling arm of this same check) and `filter-preset-ordering-comparand-refused` (a shape that never executed usefully) both registered a semantic entry under protocol major 18. - Added: `packages/spec/src/migrations/entries/semantic/18.view-filter-rule-absent-value-refused.ts`. `packages/spec/src/migrations/registry.ts` was regenerated by `pnpm --filter @objectstack/spec gen:migration-registry` and not hand-edited; `check:migration-registry` is green. No D2 conversion: there is no value to infer. - `check-adr-0087-registration --base origin/main` reads the changeset as `[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused (new here)`. - `spec-changes.json` and `docs/protocol-upgrade-guide.md` did not move. The protocol-18 step stays inert until the protocol major reaches 18, and `check:spec-changes` / `check:upgrade-guide` are green without regeneration. ## Changeset (step 7) `.changeset/19751-view-filter-rule-absent-value-refused.md`, `minor` on `@objectstack/spec`. `files[]` ships `dist` and `src/**/*.zod.ts`, and both carry the refinement. Its summary is the user-visible change: a stored view filter rule with no value on a value-taking operator is now refused at save instead of failing every query. It carries the BREAKING banner, a FROM/TO block, `Clause-②: no (narrowing)` and the registered disposition marker. One sentence names that it reverses the carve-out the still-pending objectstack-ai#19514 changeset records (an omitted value "still parses", an absent comparand "is left unjudged"), so the two entries do not contradict each other in the compiled CHANGELOG. The objectstack-ai#19514 file itself is not edited. Level: `minor`. An accept-set narrowing declared `(narrowing)` is BREAKING (AGENTS.md, Post-Task Checklist step 3), and during the launch window a breaking change ships as `minor`: the header of `scripts/check-changeset-no-major.mjs` says "During the launch window we ship breaking changes as `minor`", and that the BREAKING banner and the ADR-0087 disposition carry the break, not the level. Both precedents above shipped `minor` with the same banner. The first round graded this `patch`; the at-tier contract review (record 5808364674) failed that, and the patch-round commit ab0104d changes the frontmatter to `minor` and rewrites the banner sentence to state the convention ("Shipped as `minor` under the repo's launch-window convention for accept-set narrowings"). That commit moves no package file. The PR's `Clause-②: no (narrowing)` line is the claim's, copied verbatim, and matches the changeset's line. With the arm present, the level axis of `check-changeset-no-major.mjs` judges the level instead of standing down. Measured offline with `--event` on this body, it refuses the first round's `patch` head 22a14a1 (exit 1) and passes `minor` at ab0104d (exit 0). ## Fixtures, examples and pins (step 5) - An AST scan of every tracked `.ts` / `.tsx` / `.mts` / `.js` / `.mjs` / `.json` outside `content/docs/references/` (1,739 files mention `operator`) found 311 object literals with `field` plus a string-literal value-taking operator (aliases folded). 20 of them have no `value` key, and none is a view filter rule in a shipped example or seed: - 7 are QA assertions (`expectedValue`, a different schema) in `examples/app-showcase/qa/platform-smoke.test.json`; - 5 are QA assertions in `packages/core/src/qa/runner.test.ts` and `packages/spec/src/qa/testing.test.ts`; - 1 is a skill trigger condition in `packages/spec/src/ai/skill-trigger-condition-value-shape.test.ts`; - 4 are a structural walk with no schema in `packages/metadata-protocol/src/protocol.graft-normalized-operators.test.ts`; - 3 are in `view-filter-rule-value-shape.test.ts`. Markdown (`.md` / `.mdx`) has no match. No fixture was an authoring mistake, so no fixture was edited. - Pins that pinned the removed carve-out and moved with it: - `packages/spec/src/ui/view-filter-rule-value-shape.test.ts`: `equals + omitted` and `greater_than + omitted`, from accepted to refused. - `packages/spec/src/data/filter-icontains-parse-door.test.ts`: "ABSENCE is left unjudged" now asserts that absence is refused once, in the absent-value arm's words and never in the conformance table's.⚠️ This file is outside the claim's declared file surface. It is a view-filter-rule test that lives in `src/data/`, not beside `view.zod.ts`, and it had to move with the carve-out it pinned. - Carriers named in the docblock (`ListView.filter`, a tab filter, `Page.filterBy`, a related-list filter, a lookup picker filter, plus `ObjectGridProps.defaultFilters`) are all `z.array(ViewFilterRuleSchema)`. The full spec suite is green, and a new pin drives the refusal through `ListView.filter` at `filter.1.value`. ## Tests - New pins, with operator lists derived at runtime: value-taking is `VIEW_FILTER_OPERATORS` minus the four valueless operators. The valueless set is module-private in `view.zod.ts` and deliberately not exported, so the test reuses the file's existing transcription, and a new two-way sweep holds that transcription equal to the private set by behaviour: over every operator, an absent value is accepted exactly when the operator is valueless. - `vitest run --project local` on `view-filter-rule-value-shape.test.ts` and `filter-icontains-parse-door.test.ts`: 104 passed. - Firing control at 325052f, through `scripts/ablation-replace.mjs`: the anchor `if (value === undefined) {` was replaced by `if (value === undefined) return;` followed by `if (false) {`, which is the base behaviour (an absent value returns before any issue). The anchor went 1 to 0 and the blob b6b2f44 to 8c2839db. Result: **11 new pins red, 57 green**. After the restore, the blob equals HEAD and `git diff HEAD` is empty. A first attempt was refused by the tool before anything ran, because its replacement contained the anchor; nothing was measured on that attempt. - Full spec `local` project at 22a14a1: 522 files passed, 15,420 tests passed. One file skipped by its own stale-dist condition (`scripts/root-entry-type-nameability.pin.test.ts`); after a rebuild at the same head it ran with `OS_EXPECT_ROOT_NAMEABILITY=1`: 2 passed. - Spec `repo` project at 22a14a1: 34 files, 587 tests passed. - `pnpm --filter @objectstack/spec typecheck` at 22a14a1: exit 0 (tsc, scripts typecheck, `check:test-typecheck` OK). ## Gates `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at 22a14a1 derived 86 commands. All were run and reconciled with `--ran`: 84 exit 0, 2 NOT MEASURED, 0 unrun. The two NOT MEASURED are `check:dual-build-cjs-loads` and `check:type-check-debt`, both exit 3 PREREQUISITE NOT MET because they need the whole-workspace build. They are left to CI. `check:generated` is 15/15 up to date after a fresh build at that head. Generated files that moved: `packages/spec/src/migrations/registry.ts` only, via `gen:migration-registry`. Patch round at ab0104d: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 86 commands from a tree 53 commits behind origin/main 2c1011b. origin/main's selector over the same six paths adds one family, `check:migration-registry`, which was run too (exit 0). The `--ran` reconciliation reads 84 run, all exit 0, 2 NOT MEASURED (`check:dual-build-cjs-loads`, `check:type-check-debt`: exit 3 PREREQUISITE NOT MET, they need the whole-workspace build, left to CI), and 0 unrun. `check-changeset-no-major --base origin/main` prints "This diff introduces no `major` bump." `check-adr-0087-registration --base origin/main` reads `[BREAKING+clause-②-narrowing] registered view-filter-rule-absent-value-refused`. CI at ab0104d: 35 check runs, 32 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke), 0 failure. ## Scope held - In `view.zod.ts`, only `checkViewFilterRuleValueShape` and its docblock changed. The `ViewFilterRuleSchema` block is untouched: its JSDoc and `.describe()` are still true. None of PR objectstack-ai#19809's regions is touched. - `FILTER_TEXT_CASES` gains no row. There are no objectui or runtime (`parseFilterAST`) edits. - `origin/main` has moved 8 commits past the branch point: objectstack-ai#19598 touches the `ListView` shape in `view.zod.ts`, and objectstack-ai#19657 touches `registry.ts`. A driver-less `merge-tree` of HEAD onto `origin/main` 8cbc3c0 is clean. Main is not merged in. ## Acceptance notes - The sibling entry `view-filter-rule-scalar-operator-array-refused` says, in its replacement prose, "An omitted value is still an omitted value". That was true of its own arm; after this change an omitted value on a scalar operator is refused. Both entries sit in the uncut protocol-18 step. The new entry's leading comment names the reversal, and the sibling's text was left as it is (it is outside this card's file surface). - `checkViewFilterRuleTextComparand`'s docblock, carve-out 1, says an omitted comparand "is left to whatever judges absence". That stays true: the shape arm now judges it. Not edited. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…when read as one release (objectstack-ai#19918) Fixes objectstack-ai#19851 Clause-②: no ## What this changes Changeset text only: two PENDING notes on `origin/main`, `.changeset/18975-connector-retry-config-and-request-timeout.md` and `.changeset/19580-retire-connector-connection-timeout-ms.md`, `+47 / -29`. No code, no other file, and no frontmatter line: packages and levels are byte-identical to the merge base. Both notes go into the same next release. The last tag is `@objectstack/*@17.4.0`. On npm, `latest` for `@objectstack/spec`, `connector-rest`, `connector-openapi`, `connector-mcp`, `connector-slack` and `service-automation` is `17.4.0`, published 2026-09-09, with no version after it. Read together as that one release, the base text said that `ConnectorProviderContext` **gains** `connectionTimeoutMs`, and also that the same member, called "a published interface member", is **removed**. It also misstated what the residue stage does with the default value, and it anchored its read census to a moving ref. After this PR, every sentence the two notes publish is true of what that release ships. ## DELIBERATE CORRECTION: this PR awaits the maintainer's written confirmation, and `Check Changeset` is red on purpose This PR adds no changeset of its own. It changes two pending changesets it did not add. Route 0's discriminator against the merge base: ``` $ git diff --name-status 8490127 HEAD -- '.changeset/*.md' M .changeset/18975-connector-retry-config-and-request-timeout.md M .changeset/19580-retire-connector-connection-timeout-ms.md ``` Every row is `M` and none is `A`, so this is the DELIBERATE CORRECTION class that `scripts/check-empty-changeset.mjs` names. It is not a COLLISION: nothing of this PR's own was overwritten, and restoring the base copies would republish the false sentences. - ⛔ No `skip-changeset` label, and ⛔ no new changeset file (ruling D on objectstack-ai#18375). - `Check Changeset` stays **advisory red**. It is not one of the seven required contexts. Every other check should be green. - **This PR awaits the maintainer's one-sentence confirmation of the correction.** The owning seat carries it to this PR with its provenance (who, the words, where). The PR is not to be armed before that confirmation is on it. ## Measurements (taken before editing a word) The worktree was cut at merge base `8490127962`. The checkout is a full clone (`git rev-parse --is-shallow-repository` answers `false`). ### M1: the three members entered after 17.4.0, and no release carried them | Question | Instrument | Reading | |---|---|---| | When did each member enter and leave? | `git log -G 'connectionTimeoutMs\?:' origin/main` on `connector-provider.ts`, `rest-connector.ts` and `openapi-connector.ts` | For all three files, it entered with `b929e0a662` (2026-09-20, objectstack-ai#19388) and left with `fc29c74400` (2026-09-23, objectstack-ai#19657). | | Is `b929e0a662` in any release? | `git tag --contains b929e0a` | 0 tags. | | Same question, by ancestry | `git merge-base --is-ancestor b929e0a '@objectstack/spec@17.4.0'` | **exit 1**. The control leg is `@objectstack/spec@17.3.0` (`8a1bad8b8e`), in the same checkout, against the same target, an older commit: **exit 0**. | | Is 17.4.0 the newest release? | `git ls-remote --tags origin 'refs/tags/@objectstack/spec@*'` and `npm view @objectstack/spec dist-tags time` | The newest tag is `17.4.0`, which peels to `7e6337007f`. The `connector-rest`, `connector-openapi` and `service-automation` 17.4.0 tags point at the same commit. npm has `latest` `17.4.0` and `rc` `17.0.0-rc.6`, and nothing was published after 2026-09-09T03:57Z. | | What did 17.4.0 ship? | `npm install @objectstack/spec@17.4.0`, then read `dist/integration/index.d.ts` | `interface ConnectorProviderContext` has `name`, `label`, `description`, `icon`, `type`, `providerConfig`, `auth` and `loadPackageFile`. It has no `connectionTimeoutMs`, and no `retryConfig` or `requestTimeoutMs` either. | | same | `npm pack @objectstack/connector-rest@17.4.0` and `@objectstack/connector-openapi@17.4.0`, then read every `.d.ts` | `interface RestConnectorOptions` and `interface OpenApiConnectorConfig` are present (the control), with **0** `connectionTimeoutMs` occurrences in any `.d.ts`. | | What does the next release ship? | `packages/spec/src/integration/connector-provider.ts:67` at the merge base | `ConnectorProviderContext` declares `retryConfig` and `requestTimeoutMs`. `connectionTimeoutMs` appears only in a "REMOVED" comment. `rest-connector.ts:42` and `openapi-connector.ts:130` have comments only. | The authorable key **was** published. In the 17.4.0 source, `connector.zod.ts:839` reads `connectionTimeoutMs: z.number().min(1000).max(300000).optional().default(30000)`, and `DeclarativeConnectorEntrySchema` (`:955`) is built on `ConnectorSchema`. The M2 probe of the released 17.4.0 package shows `ConnectorSchema` accepting and keeping an authored `15000`. So "a published authorable key is removed on two carriers" stays true, and it is now the only thing the Clause-② sentence rests on. ### M2: parse probes, with the path each refusal is reported at For today's tree, `tsx` ran against `packages/spec/src` at the merge base, for four carriers. The entry is `{ name: 'ledger_api', label: 'Ledger API', type: 'api' }` plus the key: | `connectionTimeoutMs` | `ConnectorSchema` | `DeclarativeConnectorEntrySchema` | `getMetadataTypeSchema('connector')` | `ObjectStackSchema` (`connectors: [entry]`) | |---|---|---|---|---| | absent (control) | accept, key absent | accept, key absent | accept, key absent | accept, key absent | | `30000` | **accept, key stripped** | **accept, key stripped** | **accept, key stripped** | **accept, key stripped** | | `15000` | refuse `invalid_type` @ `connectionTimeoutMs` | same | same | refuse `invalid_type` @ `connectors.0.connectionTimeoutMs` | | `1000` | same as `15000` | same | same | same | | `"30000"` (string) | same as `15000` | same | same | same | Every refusal message names `requestTimeoutMs`. In every accepted case `requestTimeoutMs` still reads `30000`, the control that shows the stage leaves the live sibling alone. `ConnectorSchema` is a `pipe` whose input stage is a `transform`. The tombstone **without the stage** is the pipe's inner object. It refuses `30000` at `connectionTimeoutMs`, and it accepts the same entry with the key absent (the control). Released side: `@objectstack/spec@17.4.0` from npm emits `connectionTimeoutMs: 30000` for that entry on `ConnectorSchema`, on `DeclarativeConnectorEntrySchema` and on `ObjectStackSchema`, and it accepts and keeps an authored `15000`. `npm pack` of `connector-mcp`, `connector-openapi`, `connector-rest` and `connector-slack` at `17.4.0` each ship one literal `connectionTimeoutMs: 3e4` in their JS. ### M3: which sites READ the key (one number: **six**) The instrument is `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'`, keeping non-test code files. It was run at `e07843b5a6`, the parent of the landing commit `fc29c74400`, and gives an identical result at `6eaa0f4a81`, the review's merge base. The result is 13 occurrences, 7 files, 5 packages: - **6 reads:** `openapi-connector.ts:242`, `openapi-provider.ts:193`, `rest-connector.ts:134`, `rest-provider.ts:64`, `plugin.ts:307`, `plugin.ts:1589` - 4 type declarations: `openapi-connector.ts:135`, `rest-connector.ts:47`, `plugin.ts:291`, `plugin.ts:339` - 3 literal `30000` writes: `mcp-connector.ts:247`, `slack-connector.ts:94`, `plugin.ts:1782` Six read expressions sit at six file:line locations. The "five sites" phrasing elsewhere counts the two `?? 30000` fallbacks as one site: it is the same set in a different unit (see the acceptance notes). Within the changesets, the one count used is **six reads**, and the tree it was taken on is now named. `0870fb5418`, which the note cites for the earlier census, re-measures at exactly five hits, all `connectionTimeoutMs: 30000,`, as the note says. ## Old and new, per file ### `.changeset/18975-connector-retry-config-and-request-timeout.md` **(a)** This release does not add a member that the same release removes (M1). > old: `ConnectorProviderContext` gains `retryConfig`, `connectionTimeoutMs` and `requestTimeoutMs`, read-only and resolved from the entry … > new: `ConnectorProviderContext` gains `retryConfig` and `requestTimeoutMs`, read-only and resolved from the entry … **(b)** The key is no longer described as "carried onto `ConnectorProviderContext`" and "owed a decision". The same release retires it, and no release carries it on the context (M1). The mapping and the ledger still record the reason: `connector-fetch-policy.ts:57` and `liveness/connector.json` `props.connectionTimeoutMs.status: dead`, both at the merge base. > old: **⚠️ `connectionTimeoutMs` is NOT enforced, deliberately, …** … So it is carried onto `ConnectorProviderContext` (a custom provider on a transport that *can* separate the phases may honour it) and left unenforced by the platform, with the reason recorded at the mapping and in `packages/spec/liveness/connector.json`, which keeps that one row `dead`. It is owed a second, narrower ADR-0049 decision: retire it, or re-describe it as something the platform can enforce. > new: **⚠️ `connectionTimeoutMs` is NOT made live, deliberately, …** … So this change leaves it unenforced, with the reason recorded at the mapping and in `packages/spec/liveness/connector.json`, whose row for it stays `dead`. That left it owed a second, narrower ADR-0049 decision, and this same release takes it: `connector.connectionTimeoutMs` is **retired**, and its own entry in this release says what to write instead. The key never reaches `ConnectorProviderContext` in any release. **(c)** The claim that the schema "keeps every key" is scoped to the change it describes. Read as a claim about the release, it is false twice. `connectionTimeoutMs` is retired by `fc29c74400`. `syncConfig.schedule` is deleted by `929d9e3f20`: present at 17.4.0, absent at the merge base, and `git merge-base --is-ancestor 929d9e3 '@objectstack/spec@17.4.0'` gives exit 1. As a claim about `b929e0a662` itself it holds: that commit changes 0 non-comment lines of `connector.zod.ts`, the file that also holds `RetryConfigSchema`. > old: Nine of the ten ledger rows flip `dead` → `live` with the consumer site named. No declaration moves: the connector schema keeps every key, every bound and every default it had. > new: Nine of the ten ledger rows flip `dead` → `live` with the consumer site named; the tenth is `connectionTimeoutMs`, above. This change itself moves no declaration: it leaves every key, every bound and every default on the connector schema as it found them. ### `.changeset/19580-retire-connector-connection-timeout-ms.md` **(d)** The never-released member is no longer called published. The Clause-② sentence now rests on the authorable key alone (M1). The line's leading token is unchanged, and `readClause2Line` reads it identically at base and head. > old: `Clause-②: yes (narrowing)` — a published authorable key is removed on two carriers and a published interface member leaves `ConnectorProviderContext`, so the accept set a consumer writes against narrows. > new: `Clause-②: yes (narrowing)` — a published authorable key is removed on two carriers, so the accept set a consumer writes against narrows. **(e)** The migration no longer tells a released-version factory to stop reading something it never had (M1). One paragraph says which members were never released and who could have read them. > old: **The one-line fix: delete the key** — and, for a custom provider factory, stop reading `ctx.connectionTimeoutMs`. > new: **The one-line fix: delete the key.** … The three interface members withdrawn with it were **never in a release**: `ConnectorProviderContext.connectionTimeoutMs`, `RestConnectorOptions.connectionTimeoutMs` and `OpenApiConnectorConfig.connectionTimeoutMs` all entered with `b929e0a662`, after the `@objectstack/*@17.4.0` tag, and leave in this same release. A factory or caller built against a released version never saw them; only code written against an unreleased `main` in between can read them, and it stops. Two sentences follow from the same reading. The FROM → TO row for `ConnectorProviderContext.connectionTimeoutMs` gains "added after `@objectstack/spec@17.4.0` and never in a release, see below". The D3 bullet now says the removal reaches "a factory author who read it — possible only against an unreleased `main` —" rather than any factory author. **(f)** The residue stage is stated as measured (M2). > old: … measured across two builds: the base build emits it for an entry that authored only `name`/`label`/`type`, and the tombstoned build refuses that exact object at `connectors.0.connectionTimeoutMs`. > new: … measured on both sides of the retirement: the released `@objectstack/spec@17.4.0` emits `connectionTimeoutMs: 30000` for an entry that authored only `name`/`label`/`type`, and the tombstone **without the stage** refuses that exact object at `connectionTimeoutMs`. With the stage, as it ships, that object is **accepted and the key stripped** before the tombstone reads it — on `ConnectorSchema`, `DeclarativeConnectorEntrySchema`, the `/meta/connector` schema and `stack.connectors[]` alike. > old: … and all four shipped connector packages put the materialized value straight into that def literal. So the emitted `30000` is accepted-and-stripped while `15000` keeps the tombstone's refusal, … > new: … and in 17.4.0 all four shipped connector packages put that `30000` straight into the def literal. So the emitted `30000` is accepted-and-stripped, while every other value (`15000`, `1000`, the string `"30000"`) keeps the tombstone's refusal — at `connectionTimeoutMs`, or at `connectors.0.connectionTimeoutMs` inside a stack — … The tombstone bullet's "`stack.connectors[]` and the `/meta/connector` door refuse it too" gains "every value but the retired default `30000`, which the residue stage below strips first". Without that clause, the bullet contradicted the residue bullet. **(g)** The read census names its tree (M3). > old: Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at `origin/main`: **thirteen** … > new: Measured with `git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'` at `e07843b5a6`, the tree this retirement landed on: **thirteen** … ### What deliberately did not change - **Frontmatter, both files.** No level is wrong after the correction: - 18975's `minor`s cover real widenings: `ConnectorProviderContext` gains `retryConfig` and `requestTimeoutMs`, and the provider options and `resilientFetch` gain knobs. - 19580 stays `@objectstack/spec: minor` with the **BREAKING** banner, because the launch window refuses `major`. - 19580's `patch` for the connector and service packages is now better supported: the correction states outright that the option fields those packages withdraw were never released. - **The ADR-0087 marker and the remaining sentences.** The marker line is unchanged, and so is every sentence not quoted above. - **What the changeset gates read.** Base and head get the same reading from `check-adr-0087-registration` and `readClause2Line`: - 19580: `breaking true ["BREAKING","bang"]`, disposition `registered` with the same two ids, migration prescription found. - 18975: non-breaking, no migration prescription, `Clause-②: yes (widening)` declared. - An intermediate wording (`654adaba14`) mentioned the retirement entry's migration table by its house label. The detector's label branch read that as a migration prescription on 18975. It was reworded in `baf93b20fd`, and the parity above was re-measured on `f4fbb2203e`. ## Local gates, on `f4fbb2203e` The gate list is derived by `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 19 commands. Reconciled with `--ran` and exit codes recorded: `19 derived, 19 run, 0 NOT-MEASURED, 0 UNRUN`. Every exit code was captured before any pipe. | Command | Exit | |---|---| | `node scripts/check-empty-changeset.mjs --base origin/main` | **1**, as intended: `✓ No empty-frontmatter changeset introduced`, then "This PR changes a changeset it did not add" naming both files, with the DELIBERATE CORRECTION remedy | | `node scripts/check-adr-0087-registration.mjs --base origin/main` | 0 (`✓ … this PR adds no declared-breaking changeset (2 non-breaking changeset(s) seen)`) | | `node scripts/check-changeset-no-major.mjs --base origin/main` | 0 (`✓ This diff introduces no major bump.`; the LEVEL axis is not applicable without a `pull_request` payload) | | `node scripts/check-changeset-fixed.mjs` (not derived, run as a `check-changeset*` gate) | 0 (`✓ … "fixed" group is in sync with 70 public workspace packages.`) | | `pnpm check:changeset-gate-self-tests` | 0 (159, 441 and 339 assertions) | | `pnpm check:nul-bytes` | 0 (`OK … no raw ASCII control bytes`) | | the `--self-test` of `check-adr-0087-registration`, `check-changeset-no-major` and `check-empty-changeset`, plus `check-closing-keyword-parity` (both), `check-comment-mask-corpus`, `pm/release-rehearsal-clone --self-test`, `check:driver-memory-census`, `check:gitlink-declared`, `check:objectui-changeset`, `check:pm-changeset-deadline-census`, `check:published-files`, `check:refd-timer-probe`, `check:watch-hint-literal` | 0 each | NOT MEASURED: the four type-check programs the derivation lists outside the derived total, which are CI's whole-workspace lanes. Reason: the diff touches no TypeScript, and no workspace build was bought for a text-only diff. Also scanned for control bytes (none, with a lit positive control) and for model identifiers in the diff (0 hits). ## Acceptance notes - **"five sites … READ" is still the wording in four code docblocks.** `packages/spec/src/integration/connector.zod.ts:565`, `packages/spec/src/conversions/registry.ts:9078`, `packages/spec/src/integration/connector-connection-timeout-retirement.test.ts:15` and `packages/spec/src/migrations/registry.ts:5255` say that. The retired-key entry (`18.integration__Connector__connectionTimeoutMs.ts:14`, `migrations/registry.ts:14678`), the liveness row and the 19580 changeset say "six reads". It is the same set, with the two `?? 30000` fallbacks counted as one site or two. Not touched here, because this PR is changeset text only. Carrier: none. - **The retired-key entry and the liveness `_note` still anchor the read census "at `origin/main`" without a sha.** This is the moving-ref reading corrected in the changeset by (g). Code and data, not changeset text, so not touched here. Carrier: none. - **The 19580 note's `Clause-②` sentence reads as `near-miss` (reason `describing`) in `readClause2Line`.** That is so at base and at head, because the token sits in backticks with prose after it. `check-adr-0087-registration` therefore classifies the note as breaking through the banner and the `!`, not through signal (4). It is left as it was: changing the line would change what the gate reads, which is outside a correction of false sentences. --- _Generated by [Claude Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19580
Clause-②: yes (narrowing)Ruled: comment
5770606746, batch #211 item 1, letter A — retireconnector.connectionTimeoutMs(ADR-0049 enforce-or-remove; the standing 2026-09-10 「以协议为准」 ruling; the 2026-08-27 「no staged retirement」). Removal route: thespec-property-retirementplaybook.What this removes
A fully authorable key — bounded (
min(1000).max(300000)), defaulted (30000),.describe()d, writable onConnectorSchemaand, throughDeclarativeConnectorEntrySchema, onstack.connectors[]andPUT /meta/connector/:name, and served back by/meta/connector. Every signal an authoring surface can give said it worked. Plus theConnectorProviderContext.connectionTimeoutMsmember handed to every provider factory.requestTimeoutMsis the replacement: the deadline the platform actually keeps, applied asresilientFetch's per-attempt timeout.⭐ The measurement the ruling left to the dev — D2 or D3
The ruling prescribed a D3 semantic entry and said a D2 conversion is owed only if a stored connector row can carry the key — 「the dev measures」. It can, so both ship.
Measured first-hand on this branch, before the tombstone landed, against the built
packages/spec/dist:getMetadataTypeSchema('connector')bound?true— it resolvesDeclarativeConnectorEntrySchema, the shapePUT /api/v1/meta/connector/:namevalidates againsttrue4321— so the number reachessys_metadataerrorMappingon the same schema is refused — the door discriminates rather than accepting everythingapplyConversionsToStoredItem('connector', row)live for this type?connector-error-mapping-removedand stripped that key from a stored rowrequestTimeoutMssurvived untouched, so the strip is attributableWhat would have made it the other answer:
getMetadataTypeSchema('connector')returningundefined(no write door ⇒ no stored row), or the door's output dropping the key, or the rehydration seam never reachingconnectorrows. All three fired the other way, so a D2 conversion is owed and a D3-only kit would have left 17.x rows carrying a key the schema now refuses.Both dispositions are re-measured by pins in
packages/spec/src/integration/connector-connection-timeout-retirement.test.ts, so the answer cannot rot into an assumption.⛔ This section's earlier premise is known false and is replaced rather than patched. It claimed the card's Leg-2 table 「was already false when this retirement was taken」. It was not.
At the SHA the card cites and dates —
0870fb5418—git grep -n connectionTimeoutMs SHA -- . ':!packages/spec'returns exactly five non-spec source hits, and all five areconnectionTimeoutMs: 30000,: the card's table, line for line. ⇒ the card was correct when measured. What moved it isb929e0a662(#19388) — the very PR the card itself flagged as pending.At⚠️ Seven files, not five — five is the count of packages, and conflating the two is how the earlier number was reached.
origin/mainthe same instrument returns thirteen non-test source occurrences over seven files in five packages: six reads, four type declarations, and three surviving pure hardcoded30000writes (connector-mcp/src/mcp-connector.ts,connector-slack/src/slack-connector.ts,service-automation/src/plugin.ts).services/service-automation/src/plugin.ts:307entry.connectionTimeoutMsinto the materialization fingerprintservices/service-automation/src/plugin.ts:1589ConnectorProviderContextconnectors/connector-rest/src/rest-provider.ts:64ctx.connectionTimeoutMsconnectors/connector-openapi/src/openapi-provider.ts:193ctx.connectionTimeoutMsconnector-rest/src/rest-connector.ts:134,connector-openapi/src/openapi-connector.ts:242?? 30000— read the opts and deposit the value on the reported defThe ruling's premise survives, and the mechanism is unchanged. Every read is a pass-through. The value's only termini are (a) the def
GET /connectorsechoes and (b) the fingerprint that decides whether to re-materialize.connectorFetchOptions()(integration/connector-fetch-policy.ts) is handed{ retryConfig, requestTimeoutMs }only, and a pin has asserted since #18975 that nothing aliases this key ontotimeoutMs. Carrying a number is not honouring it — the parsed-unmarked-unenforced state ADR-0049 forbids, wearing a longer route.Nor was the
实现arm available: a WHATWGfetchexposes oneAbortSignalover the whole operation and never the connect phase, so bounding time-to-response with this key would kill a slow-but-connected upstream the author meant to allow with a largerequestTimeoutMs.Zero-enforcement verification, with its control
git grep -n connectionTimeoutMsover the whole worktree (45 hits, hand-read, not counted) plus the source ofconnectorFetchOptions(). Radius: the monorepo. Control:requestTimeoutMs— same schema, same census, same files — resolves to a real read (opts.timeoutMs = policy.requestTimeoutMs), so the instrument is not dead.git grep connectionTimeoutMsat objectui87af769e9a3ee28ace099fdd653d3ebd79fe82e2(the.objectui-shapin) → exit 1, zero hits; controlconnectoron the same command and scope returns 458 lines across 66 files; andrequestTimeoutMsis exit 1 / 0 lines there, so it is not a usable control in that repo (it is in objectstack). ⇒ theConsole Pin Gateneeds no sibling fix and no pin bump with this removal.z.preprocesspipes, and objectui'spackages/app-shell/src/views/metadata-admin/clientValidation.optOuts.test.ts:468assertschecks(DeclarativeConnectorEntrySchema) > 0— 1 againstmain, 0 here, because a pipe def has nochecksarray. The SPA still builds andConsole Pin Gatenever runs that suite, so no gate in either repo sees it. objectui resolves@objectstack/specfrom the registry at^17.0.0, so ⛔maindoes not go red on merge — the break lands at objectui's next spec bump. Tracked at objectui#10211; the gate-reach gap at objectstack#19692. ⛔ A.objectui-shabump is never a rider on another PR, so neither rides here.retiredKey()types the keynever, so every authoring site in the monorepo fails to compile. All six affected packages typecheck green after the cleanup, which is what says the census is complete rather than the grep.The retirement kit
retiredKey()tombstone on the non-strictConnectorSchema(a bare delete would be a silent strip, ADR-0104), inherited byDeclarativeConnectorEntrySchema.RETIRED_KEYS_BY_MAJOR[18]× 2 —integration/Connector:connectionTimeoutMsandintegration/DeclarativeConnectorEntry:connectionTimeoutMs— as one-file-per-entry undermigrations/entries/retired-keys/.connector-connection-timeout-ms-removedinconversions/registry.ts, wired into the step-18 chain.acceptRetiredDefaultResidueon both carriers with{ connectionTimeoutMs: 30000 }. A D2 does not discharge this: the ruled precedent18.security__ObjectPermission__allowPurgecarries both a D2 (permission-allow-restore-purge-removed) and the residue stage, so D2 coverage cannot be the discriminator. The discriminator is whether a released toolchain MATERIALIZED the default — a 17.x toolchain emitsconnectionTimeoutMs: 30000into every connector entry, authored or not — and the second door isAutomationEngine.registerConnector, which parsesConnectorSchemafor a def a plugin builds in code, where no conversion runs. Without the stage a 17.x connector package fails registration on a value its author never typed. Head now accepts-and-strips30000while still refusing15000,1000and"30000". The preprocess pipe this introduces moves five ADR-0097 refinement sites onto its OUT side, sodropped-refinements.baseline.jsonmoves with them — exactly the moves the build gate printed, no additions.connector-provider-context-connection-timeout-ms-retiredfor the withdrawnConnectorProviderContextmember — a provider factory is code, so there is no authored source for a conversion to rewrite.liveness/connector.json: the row staysdeadwith aREMOVEDnote, becauseretiredKey()keeps the key in the walked shape (therls.priorityprecedent). Its stale 「every occurrence outsidepackages/specis a WRITE」 claim is corrected there, with the reads named.authorable-surface/integration.jsongains two[RETIRED]rows,authorable-defaults/integration.jsonloses the two= 30000rows.api-surface/andjson-schema.manifest/are byte-identical — the correct reading for a key-only tombstone that retires no def, not a missed regeneration.service-automation(fingerprint, declared-item shape, context build, degraded husk).packages/spec/liveness/README.md'sconnectorrow asserted, present tense, that the entry schema isConnectorSchema.superRefine(...)— and rested its byte-identical-key-set conclusion on that attachment. Both halves are corrected: the mechanism is now the pipe's read-throughshape, and the conclusion is re-measured rather than inherited (30 keys each carrier, byte-identical, zero entry-only, zero base-only)..gitattributes:71-77splitsliveness/state-counts.md(driver-managed numbers) fromliveness/README.md(hand-written Notes prose), because 「regenerating a Note would fabricate a verdict」.ZodObjectcombinators leave both published exports. WrappingConnectorSchemaandDeclarativeConnectorEntrySchemain the residue stage makes themz.preprocesspipes, so.extend(),.omit(),.pick(),.partial(),.merge(),.strict(),.keyof()and.safeExtend()no longer exist on them. Build on the object and re-wrap —acceptRetiredDefaultResidue(<the extended object>, { connectionTimeoutMs: 30000 }), theEffectiveObjectPermissionSchemaroute..superRefine()still exists on a pipe and is callable, but returns a schema with no read-throughshape— which is exactly what the schema walkers duck-test — so refine before wrapping, never after. Parsing,z.input/z.inferand the read-through.shapeare unchanged. The changeset's FROM → TO carries this row; the docblock atconnector.zod.tsand the superseded sentence it replaces carry it in the source.Clause-②: yes (narrowing),minoron@objectstack/spec(the launch-window gate refusesmajor),patchon the five consumer packages, with the FROM → TO table and the ADR-0087 disposition marker.Tests and gates run locally
pnpm --filter @objectstack/spec buildtypecheck× 6 (spec,connector-rest,connector-openapi,connector-mcp,connector-slack,service-automation)test× 5 consumer packagessrc/integration src/conversions src/migrations+ the migrate-sentence and cron pinstest:repopnpm build+ the re-derived 112-command gate sweep on this headPREREQUISITE NOT METfrom unbuilt packages ⇒ read as NOT MEASURED, ⛔ never as failures, and re-run green after the build)--project repocheck:generatedcheck:docsandcheck:livenessincludedcheck:generatednames as not runcheck:nul-bytes,check:cross-package-test-inputs,check:adr-0087-registration,check:changeset-no-majorEvery exit code above was captured before any pipe. The repo-wide gate farm is CI's run, not this PR's local obligation.
Acceptance notes
content/docs/**off.content/docs/references/integration/connector.mdxis an auto-generated baseline whose gate (check:docs) is inside the requiredTypeScript Type Checkjob, and it goes stale on this change alone. Measured across all 19 open PRs (283 file rows, 0 unreadable): zero hold that path, so the fence's stated reason — "open PRs hold files there" — does not apply to it; the instrument discriminates, returningcontent/docsrows for seven other PRs. It is regenerated here, exactly as the sibling retirement fix(spec): retiretenancy.organizationFieldfrom the authorable surface (#19054) #19618 regenerates four of the same tree's pages. No hand-writtencontent/docs/**prose is touched, andskills/**and.claude/**are untouched — this diff hits no governed surface.never, so the four connector packages andservice-automationmust stop writing it or the monorepo does not compile. Those paths are outside the claim's declared file surface and are held by zero open PRs on the same census.packages/spec/vitest.repo-tests.jsongains one line: the new tree-scoped absence pin's walk radius, whichcheck:cross-package-test-inputsdemanded by name. No new glob; the radius was already declared for this package.connectionTimeoutMs-is-never-mapped pin inconnector-fetch-policy.test.tsis kept after the retirement, deliberately: it is what makes a re-introduction as a silent alias ontotimeoutMsfail.health.circuitBreakerremainsdeadon this schema and is not touched here — a different set of rows on the same ADR-0049 worklist.Generated by Claude Code