Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
337c7d5
wip(spec): an authored connector URL holds no credential part; nested…
claude Nov 2, 2026
6d544e6
wip(spec): connector URL and nested-key pins
claude Nov 2, 2026
c0dac17
wip(dogfood): connector URL and nested-key door pins
claude Nov 2, 2026
11d2d74
wip(spec): changesets and upgrade-guide entries for the connector ent…
claude Nov 2, 2026
9880548
wip(spec): regenerate the strictness ledger counts
claude Nov 2, 2026
7c16585
wip(spec): one walk judges a connector entry's URLs at the write door…
claude Nov 2, 2026
c8eb0c9
Merge origin/main into claude/connector-metadata-hardening
claude Nov 2, 2026
b390411
wip(dogfood): seed the carried-URL connector row through the system c…
claude Nov 2, 2026
6f2a384
wip(spec): the authoring-lint pins read a nested strip site that is s…
claude Nov 2, 2026
0bd3677
wip(spec): the entry's closed nested shapes are published defs that p…
claude Nov 2, 2026
d285c12
wip(spec): name the published twins' author and parsed states; regene…
claude Nov 2, 2026
577984f
wip(spec): the entry's closed nested shapes stay private and share th…
claude Nov 2, 2026
9a0d877
Merge remote-tracking branch 'origin/main' into claude/connector-meta…
claude Nov 3, 2026
5c189e6
feat(spec): an explicit retryConfig in a connector save replaces the …
claude Nov 3, 2026
4bab398
docs(spec): the nested-key changeset and upgrade entry say an explici…
claude Nov 3, 2026
4d03a6f
docs(spec): the save answer's withheld-key-dropped advisory also cove…
claude Nov 3, 2026
7d07994
test(dogfood): read the save answer's advisories where the meta save …
claude Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/connector-entry-nested-undeclared-key-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an authored connector entry refuses a key `retryConfig` or an `actions` element does not declare

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered connector-entry-nested-undeclared-key-refused -->

**BREAKING** accept-set narrowing on an authored connector entry (`DeclarativeConnectorEntrySchema`), at every door that parses it: `defineConnector()`, `defineStack({ connectors })`, `os validate` / `os compile`, the metadata plugin's artifact load at boot and on reload, and the metadata save door `PUT /api/v1/meta/connector/:name`.

- **Before.** The entry refused an undeclared top-level key, but its two nested shapes were plain objects: an undeclared key inside `retryConfig` or inside an `actions` element parsed green and meant nothing to the runtime.
- **Now.** Each is refused by name, in one `unrecognized_keys` issue located at the container (`retryConfig`, `actions.N`) that lists every undeclared key; at the save door `422 INVALID_METADATA`, and nothing is stored. A credential-shaped key (`secret`, `token`, `apiKey`, `password`, `credentialRef`, …) meets the entry's own prescription (`auth.credentialRef`); an execution binding on an action (`method`, `path`, `url`) is refused with ADR-0097 §5's: an action carries none.
- **Unchanged.** The published open shapes — `ConnectorSchema`, `RetryConfigSchema`, `ConnectorActionSchema` — keep their posture: the def a plugin hands to `registerConnector`, the actions a provider derives, and the retry policy the runtime parses at materialization. A row stored before this refusal keeps working: the runtime parses its policy with the open `RetryConfigSchema`, which drops the undeclared key, and the read withholds it.
- **A save over such a row.** An explicit `retryConfig` replaces the stored one whole (the maintainer's ruling): a save that sends `retryConfig` — the served form, `{}`, any object — does not carry the stored row's undeclared retry key forward, and its answer names each key it dropped (advisory rule `withheld-key-dropped`). A save that leaves `retryConfig` out removes it, as before. An undeclared key in an `actions` element stays at rest through a save of the served form.

## FROM → TO

```ts
// before — parsed green; `secret` was read by nothing
retryConfig: { strategy: 'exponential_backoff', maxAttempts: 3, secret: '…' },
actions: [{ key: 'get_invoice', label: 'Get invoice', method: 'GET', path: '/invoices/{id}' }],

// after — refused at retryConfig and at actions.0; keep the declared keys only
retryConfig: { strategy: 'exponential_backoff', maxAttempts: 3 },
actions: [{ key: 'get_invoice', label: 'Get invoice', effect: 'read' }],
```

A credential moves to the secrets layer under the name `auth.credentialRef` gives; an execution binding has no home on an action (a provider-bound instance derives its actions from the upstream). The upgrade guide entry is `connector-entry-nested-undeclared-key-refused`.
34 changes: 34 additions & 0 deletions .changeset/connector-entry-url-credential-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an authored connector URL holds no credential part, and the connector read judges every `providerConfig` and `auth` URL by the same walk

Clause-②: no (narrowing)
Metadata-protocol: narrowed

<!-- adr-0087: registered connector-entry-url-credential-refused -->

**BREAKING** accept-set narrowing on an authored connector instance (`DeclarativeConnectorEntrySchema`), at every door that runs its refinement: `defineStack({ connectors })`, `os validate` / `os compile`, the metadata plugin's artifact load at boot and on reload, and the metadata save door `PUT /api/v1/meta/connector/:name`.

- **Before.** A URL under `providerConfig` or `auth` parsed green whatever it held.
- **Now.** A URL holding a userinfo (`user@` or `user:password@` before the host) or a credential query parameter with a value (`api_key`, `token`, `access_token`, `key`, `secret`, `signature` and the rest of the connector redactor's list, matched case-insensitively on the decoded name, plus the instance's own `auth.paramName`) is refused: one issue per URL, located at the URL (`providerConfig.baseUrl`, `providerConfig.transport.url`, `providerConfig.spec`, `auth.tokenUrl`, `auth.authorizationUrl`, `auth.resource`, and any other URL at any depth under `providerConfig`, array elements included), naming the parts it holds — never the URL — and prescribing `auth: { type, credentialRef }`; at the save door `422 INVALID_METADATA`, and nothing is stored. There is no rule per provider: a URL is recognised by its value.
- **One judgement, one walk, both doors.** The write door and the read redactor run one walk over an entry's URLs with one definition of a credential part, so the door refuses exactly the URLs the read rewrites or withholds. A URL inside a header or environment map (`providerConfig.headers`, `defaultHeaders`, `transport.headers`, `transport.env`) is judged by neither: the read withholds those maps whole.
- **Read.** The built-in `connector` redactor rewrites the credential parts out of every URL that walk returns, the `auth` endpoints (`tokenUrl`, `authorizationUrl`, `resource`) as well as `providerConfig`, the rest of each URL served byte for byte and the position named in `redactedKeys`.
- **Unchanged.** `ConnectorSchema` — the def a plugin hands to `registerConnector` — keeps its posture. The runtime materializes a stored row as it is stored and never re-judges it, so a connector stored before this refusal keeps dispatching with its stored URL; a save of its served form keeps the stored URL (the metadata protocol's carry-forward), and an edit of the served URL stores exactly the edit.

## FROM → TO

```ts
// before — parsed green; the secret lived in the URL
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
providerConfig: { baseUrl: 'https://billing.example.com/v1?api_key=…' },
auth: { type: 'none' } },

// after — refused at providerConfig.baseUrl; the URL is credential-free and the key is a reference
{ name: 'billing_api', label: 'Billing API', type: 'api', provider: 'rest',
providerConfig: { baseUrl: 'https://billing.example.com/v1' },
auth: { type: 'api-key', credentialRef: 'BILLING_API_KEY', paramName: 'api_key' } },
```

Set the secret under the name `credentialRef` gives (an environment variable in the open tier, or Setup → Connector credentials) and delete it from the URL. There is deliberately no automatic rewrite: the value in the URL may be the only copy of the secret. The upgrade guide entry is `connector-entry-url-credential-refused`.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2874,7 +2874,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
| **seq** | `integer` | ✅ | Monotonic sequence number of the metadata event this write appended to the item history (sys_metadata_history.event_seq). Orders writes; unlike `version` it is not an OCC token. |
| **state** | `Enum<'draft' \| 'active'>` | ✅ | Lifecycle the body was written into: "draft" when the request asked for draft mode (`?mode=draft`), otherwise "active" (published and live). A draft is staged only — it is not served to the runtime until published. |
| **projectionApplied** | `{ success: boolean; error?: string }` | optional | Outcome of the awaited ADR-0094 mutation projector — the post-persist step that materializes this metadata into its derived data-plane read model (e.g. `datasource` → its live connection pool). Present ONLY when a projector is registered for this metadata type, which is why it is optional: its absence means "no projector ran", never "the projection failed". Best-effort by design — a projector failure is reported here and logged, never thrown, so a caller that needs the read model to be live must check `projectionApplied.success` rather than rely on the 200. |
| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. The save also reports here what it did to the row at rest that the author did not write, on a draft save too: one `warning` with rule `withheld-key-dropped` for each stored position it dropped because the type's read redactor withholds it and marks it not carried forward (a connector's undeclared `auth` keys, which no door accepts and nothing reads). Its `path` names where the value sat at rest, in the same spelling (`connectors[0].auth.token`). |
| **advisories** | `{ rule: string; path: string; where: string; message: string; … }[]` | optional | Non-gating findings from the runtime authoring gate — the same shared author-time rules `os validate` / `os build` / `os lint` run, applied to this body on its way to `active`. The write SUCCEEDED; these are what the gate has to say about it anyway (closing D3). Present ONLY when at least one advisory was raised — an empty array is never emitted, so a clean save's response bytes are unchanged and absence means "nothing to report", never "the gate did not run". Advisory by construction: every entry has `severity` `warning` or `info`, because an `error` finding refuses the write and arrives as the 422 `invalid_metadata` envelope instead of here. A caller that ignores this key behaves exactly as before. Runtime-only: the CLI surfaces the same findings on its own stdout, and a Studio / MCP / AI author has no CLI at all, which is the gap this key exists to close. The gate runs on both write doors (D1), and both report: `POST /meta/:type/:name/publish` carries the same key on `PublishMetaItemResponseSchema`. The save also reports here what it did to the row at rest that the author did not write, on a draft save too: one `warning` with rule `withheld-key-dropped` for each stored position it dropped because the type's read redactor withholds it and marks it not carried forward (a connector's undeclared `auth` keys and the undeclared keys of its `retryConfig`, which no door accepts and nothing reads). Its `path` names where the value sat at rest, in the same spelling (`connectors[0].auth.token`). |
| **message** | `string` | optional | |

### Nested Shape: `SaveMetaItemResponse.projectionApplied`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `integration/` | 4 |
| `integration/` | 6 |
17 changes: 10 additions & 7 deletions packages/cli/test/package-union-readers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ const app = { name: 'unr_app', label: 'Union', navigation: [{ id: 'nav_tickets',
const ticketZh = { 'zh-CN': { objects: { unr_ticket: { label: '工单', pluralLabel: '工单', fields: { title: { label: '标题' } } } },
apps: { unr_app: { label: '联合', navigation: { nav_tickets: { label: '工单' } } } } } };
const noteZh = { 'zh-CN': { objects: { unr_note: { label: '笔记', fields: { name: { label: '名称' } } } } } };
const conn = { name: 'unr_conn', label: 'Conn', type: 'api', retryConfig: { zzzUnionProbeKey: 1 } };
const api = { name: 'unr_notes_api', path: '/unr/notes', method: 'GET', type: 'object_operation',
objectParams: { object: 'unr_note', operation: 'find', zzzUnionProbeKey: 1 } };
`;

const CONFIGS: Record<string, string> = {
Expand All @@ -119,13 +120,15 @@ export default composeStacks([svc, appStack], { manifest: 'preserve' });
i18nOne: `${PIECES}
export default defineStack({ manifest: appManifest, objects: [ticket, note], apps: [app], i18n: I18N, translations: [ticketZh, noteZh] } as any);
`,
// An undeclared connector key. \`strict: false\` keeps it past the producer's
// An undeclared authoring key. \`strict: false\` keeps it past the producer's
// parse, so the doors' own pre-parse walk is the only carrier into \`--json\`.
// It sits one level down, in \`retryConfig\` (a strip-mode shape the walk still
// visits): the connector entry's own root is closed, so a top-level key there
// is refused by the artifact load rather than warned about.
// It sits one level down, in an API endpoint's \`objectParams\` (a strip-mode
// shape the walk still visits): every collection root is closed, so a
// top-level key is refused by the artifact load rather than warned about. It
// was a connector's \`retryConfig\` until the authored connector entry closed
// its nested shapes too, after which the load refuses that key instead.
keyTwo: `${PIECES}
const svc = defineStack({ manifest: svcManifest, objects: [note], connectors: [conn] } as any, { strict: false });
const svc = defineStack({ manifest: svcManifest, objects: [note], apis: [api] } as any, { strict: false });
const appStack = defineStack({ manifest: appManifest, objects: [ticket], apps: [app] } as any);
export default composeStacks([svc, appStack], { manifest: 'preserve' });
`,
Expand Down Expand Up @@ -262,6 +265,6 @@ describe('#22238 — the undeclared-authoring-key walk reads the package union',
it('`os validate --json` carries the finding in `warnings` for a two-package app', () => {
const run = r.validateTwo;
expect(run.code, `${run.stdout}\n${run.stderr}`).toBe(0);
expect(named(run)).toEqual([expect.stringContaining('connectors.unr_conn.retryConfig.zzzUnionProbeKey')]);
expect(named(run)).toEqual([expect.stringContaining('apis.unr_notes_api.objectParams.zzzUnionProbeKey')]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
//
// The write-path inverse of the connector read projection, for the keys a
// connector does not declare: a save of the served form keeps what the read
// withheld (top level, `retryConfig`, and an `actions` element addressed by
// its `key`), and a container the author removed is never minted back.
// withheld at the top level and in an `actions` element addressed by its
// `key`; a container the author removed is never minted back; and an
// undeclared key of an object `retryConfig` is not carried forward — a save
// that sends `retryConfig` replaces the stored one whole (ruling 6593804783).
//
// Every value below is an obviously-fake placeholder.

Expand Down Expand Up @@ -40,9 +42,9 @@ describe('carry-forward of the keys a connector does not declare', () => {
expect(body.actions[0]).toEqual({ key: 'get_invoice', label: 'Get invoice' });
});

it('a save of the served form keeps every withheld value, the edited label included', () => {
it('a save of the served form keeps every carried-forward withheld value, the edited label included', () => {
const out = carryForwardRedactedValues('connector', { ...served(), label: 'Vendor catalog (edited)' }, stored);
expect(out).toEqual({ ...stored, label: 'Vendor catalog (edited)' });
expect(out).toEqual({ ...stored, label: 'Vendor catalog (edited)', retryConfig: { strategy: 'exponential_backoff' } });
});

it('an `actions` element is followed by its `key` when the author reorders the list', () => {
Expand Down Expand Up @@ -90,6 +92,35 @@ describe('carry-forward of the keys a connector does not declare', () => {
});
});

// [ruling 6593804783] An undeclared key of an object `retryConfig` is withheld
// AND not carried forward: a save that sends `retryConfig` replaces the stored
// container whole, and the drop is reported.
describe('an undeclared `retryConfig` key is not carried forward', () => {
it('a save of the served form drops it, names it, and still carries the top-level and `actions` values', () => {
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', served(), stored);
expect(item).toEqual({ ...stored, retryConfig: { strategy: 'exponential_backoff' } });
expect(dropped).toEqual(['retryConfig.secret']);
});

it('`retryConfig: {}` replaces the stored container whole', () => {
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', { ...served(), retryConfig: {} }, stored);
expect((item as Record<string, any>).retryConfig).toEqual({});
expect(dropped).toEqual(['retryConfig.secret']);
});

it('a body that removes `retryConfig` removes it, as before, and says so', () => {
const body = served();
delete body.retryConfig;
const { item, dropped } = carryForwardRedactedValuesReportingDrops('connector', body, stored);
expect(item).not.toHaveProperty('retryConfig');
expect(dropped).toEqual(['retryConfig.secret']);
});

it('the runtime gate is never told it will be restored', () => {
expect(redactedPathsCarriedForward('connector', served(), stored).sort()).toEqual(['actions.0.token', 'apiKey']);
});
});

// [#28640 ②B] An undeclared `auth` key is withheld AND not carried forward: a
// save of the served form drops it, and the drop is reported.
describe('an undeclared `auth` key is not carried forward', () => {
Expand Down Expand Up @@ -136,8 +167,10 @@ describe('an undeclared `auth` key is not carried forward', () => {
expect(dropped).toEqual(['auth.token']);
});

it('CONTROL: a row with no undeclared `auth` key reports no drop', () => {
const { dropped } = carryForwardRedactedValuesReportingDrops('connector', served(), stored);
it('CONTROL: a row with no undeclared `auth` or `retryConfig` key reports no drop', () => {
const atRest = { ...stored, retryConfig: { strategy: 'exponential_backoff' } };
const body = JSON.parse(JSON.stringify(redactMetadataItem('connector', atRest)));
const { dropped } = carryForwardRedactedValuesReportingDrops('connector', body, atRest);
expect(dropped).toEqual([]);
});
});
Loading
Loading