backend-skeleton 1.0.0-beta.1 → 1.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,17 @@ const SCHEMA_REF_PREFIX = '#/components/schemas/';
42
42
  // response/error side can have many documented statuses -- MAX_RESPONSES_PER_OPERATION bounds
43
43
  // that (real max observed: 9). See D-openapi-response-schema in DECISIONS.md.
44
44
  const MAX_RESPONSES_PER_OPERATION = 64;
45
+ // A7: source-backed passthrough caps, same "generous multiple of the real observed max" style as
46
+ // every cap above. Measured against the real Team-IZ-Backend oracle: max 9 parameters on one
47
+ // operation, max 1 security requirement per operation, exactly 1 security scheme document-wide.
48
+ const MAX_PARAMETERS_PER_OPERATION = 64;
49
+ const MAX_SECURITY_REQUIREMENTS_PER_OPERATION = 32;
50
+ const MAX_SECURITY_SCHEMES = 64;
51
+ // A8: per-status responses / non-JSON request media types. No new cap for per-status responses --
52
+ // MAX_RESPONSES_PER_OPERATION above already bounds the same `responses` map (real max observed:
53
+ // 9). MAX_REQUEST_MEDIA_TYPES is new: real max observed on one operation's requestBody.content is
54
+ // 1 (always either application/json alone or multipart/form-data alone in the oracle).
55
+ const MAX_REQUEST_MEDIA_TYPES = 16;
45
56
  // A6 (D-openapi-export): widened from `/^2[0-9]{2}$/` and `/^[45][0-9]{2}$/` to also accept
46
57
  // OpenAPI's own RANGE keys. These are ordinary in real hand-written documents and legal per the
47
58
  // official 3.1 meta-schema, whose `responses` object accepts exactly `^[1-5](?:[0-9]{2}|XX)$` plus
@@ -51,9 +62,16 @@ const MAX_RESPONSES_PER_OPERATION = 64;
51
62
  // is (correctly) not a failure, so the loss produced no warning anywhere. This is a general
52
63
  // importer capability gain, not round-trip plumbing -- it is what makes such a document readable
53
64
  // by `contract emit --openapi-file` at all.
54
- const SUCCESS_STATUS_RE = /^2(?:[0-9]{2}|XX)$/;
55
- const ERROR_STATUS_RE = /^[45](?:[0-9]{2}|XX)$/;
56
- const DEFAULT_STATUS_KEY = 'default';
65
+ // Exported (not just module-local) so contracts/export.mjs can re-check a hand-edited contract's
66
+ // `schemaFrom` claim against the ACTUAL status key it sits under, instead of trusting the string --
67
+ // same S5/persistence-integrity "never trust the on-disk contract file" posture RESPONSE_STATUS_KEY_RE/
68
+ // MEDIA_TYPE_RE already follow (Codex review finding, A8 follow-up: a hand-edited
69
+ // `sourceResponses["400"].schemaFrom:"response"` passed schema validation and exported `responseSchema`
70
+ // under HTTP 400 -- the schema's `enum` on schemaFrom checks the value is one of two strings, never that
71
+ // it agrees with the status key it's attached to).
72
+ export const SUCCESS_STATUS_RE = /^2(?:[0-9]{2}|XX)$/;
73
+ export const ERROR_STATUS_RE = /^[45](?:[0-9]{2}|XX)$/;
74
+ export const DEFAULT_STATUS_KEY = 'default';
57
75
 
58
76
  // Same shape convention as the rest of contracts/ -- operationId becomes an object key
59
77
  // downstream (contracts/emit.mjs's `operations[operationId]`), so it's whitelisted before it's
@@ -83,6 +101,33 @@ export const PATH_PREFIX_RE = /^(?:\/[A-Za-z0-9._~%-]+)+$/;
83
101
  export const COMPONENT_SCHEMA_NAME_RE = /^[A-Za-z][A-Za-z0-9_.-]{0,199}$/;
84
102
  export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
85
103
 
104
+ // A8: an OpenAPI response-object status key becomes an object key downstream, in both this
105
+ // contract's sourceResponses and the exported document's responses -- same prototype-pollution
106
+ // class OPERATION_ID_RE/COMPONENT_SCHEMA_NAME_RE guard against. A real status key is a literal
107
+ // 3-digit code (100-599), a range key (1XX-5XX), or the literal "default" -- nothing else is a
108
+ // legal OpenAPI response status, so anything else is DROPPED (not a failure), mirroring
109
+ // indexOpenApiDocument()'s own "malformed entry, safe to skip" posture.
110
+ export const RESPONSE_STATUS_KEY_RE = /^(?:[1-5](?:[0-9]{2}|XX)|default)$/;
111
+ // A8: a media-type key becomes an object key downstream too (sourceRequestBody, a per-status
112
+ // entry's mediaTypes disclosure, and the exported document itself). A real media type always
113
+ // contains a '/' (type/subtype per RFC 6838) -- that requirement alone structurally excludes
114
+ // '__proto__'/'constructor'/'toString', the same defense COMPONENT_SCHEMA_NAME_RE's leading-letter
115
+ // requirement provides one layer down.
116
+ export const MEDIA_TYPE_RE = /^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*\/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$/;
117
+
118
+ // A8: the exact stand-in text contracts/export.mjs writes into a per-status Response Object when
119
+ // the source had no description of its own (OpenAPI 3.1 requires one on every Response Object).
120
+ // Declared here, the READING side, for the identical "writer and reader cannot drift apart" reason
121
+ // BSKEL_GENERATED_EXTENSION/BSKEL_PASSTHROUGH_EXTENSION are -- applyPerStatusResponses() below must
122
+ // recognize and skip this exact string on import, or round-tripping an export whose source had no
123
+ // real description would silently launder synthetic filler text into a real contract field,
124
+ // breaking the "export -> re-import reproduces operations exactly" invariant A6/A7 already
125
+ // established. (The pre-A8 union path's own two stand-in descriptions never needed this defense --
126
+ // nothing on the import side ever reads a response object's `description`, only its `content`
127
+ // schema; this field is the first to read description back in, which is what introduces the
128
+ // hazard.)
129
+ export const PER_STATUS_NO_DESCRIPTION_STANDIN = 'The source document documents this status for this operation but gives no description.';
130
+
86
131
  // inlineSchema()'s keyword policy: RECURSED keywords are walked into; ASSERTION keywords are
87
132
  // copied verbatim (their values are scalars/arrays of scalars, not schema nodes -- nothing to
88
133
  // recurse); DROPPED keywords carry no validation meaning and are silently discarded (their
@@ -93,8 +138,14 @@ export const SCHEMA_PROPERTY_NAME_RE = /^[A-Za-z][A-Za-z0-9_]{0,127}$/;
93
138
  // emit a schema WEAKER than the real one, which is worse than emitting no schema at all -- see
94
139
  // D-openapi-request-schema in DECISIONS.md.
95
140
  const RECURSED_KEYWORDS = Object.freeze(new Set(['properties', 'items', 'additionalProperties', 'oneOf', 'anyOf', 'allOf']));
141
+ // A7: `default` added -- annotation-only per 2020-12 (Ajv runs with useDefaults off here, so it's
142
+ // inert for validation either way), but a real, human-authored fact from the source document worth
143
+ // carrying through regardless. Measured across every real request-body and response schema in the
144
+ // oracle: 0 contain `default` anywhere, so this addition provably changes zero bytes of A2/A3
145
+ // output on real data -- the only place it actually fires is parameter schemas (22/253 real
146
+ // parameters use it directly, another 9 via a `$ref`-sibling, see the $ref handling below).
96
147
  const COPIED_KEYWORDS = Object.freeze(new Set([
97
- 'type', 'enum', 'const', 'required',
148
+ 'type', 'enum', 'const', 'required', 'default',
98
149
  'minLength', 'maxLength',
99
150
  'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
100
151
  'minItems', 'maxItems', 'uniqueItems',
@@ -123,10 +174,35 @@ class InlineFailure extends Error {
123
174
  // writer and the reader cannot drift apart and silently disarm the guard.
124
175
  export const BSKEL_GENERATED_EXTENSION = 'x-bskel-generated';
125
176
 
177
+ // A7: a SECOND, per-operation marker. A passthrough-heavy export (real query/header parameters, a
178
+ // real bearer-auth requirement, real summaries/tags) reads much closer to the source oracle
179
+ // document than A6's original thin projection did, so the documented escape hatch (stripping
180
+ // `info.x-bskel-generated`) needs to get harder to trigger by accident, proportionate to how real
181
+ // the export now looks. Declared here, alongside BSKEL_GENERATED_EXTENSION, for the identical
182
+ // "writer and reader cannot drift apart" reason -- contracts/export.mjs imports both.
183
+ export const BSKEL_PASSTHROUGH_EXTENSION = 'x-bskel-passthrough';
184
+
126
185
  export function hasBskelExportMarker(doc) {
127
186
  const info = doc?.info;
128
- if (typeof info !== 'object' || info === null || Array.isArray(info)) return false;
129
- return Object.hasOwn(info, BSKEL_GENERATED_EXTENSION);
187
+ if (typeof info === 'object' && info !== null && !Array.isArray(info) && Object.hasOwn(info, BSKEL_GENERATED_EXTENSION)) {
188
+ return true;
189
+ }
190
+ // A7: OR any operation carries the passthrough marker -- closes the exact gap a passthrough-
191
+ // heavy export would otherwise leave: stripping only `info.x-bskel-generated` used to be
192
+ // sufficient to disarm the guard for ANY export; now it is only sufficient for one that carries
193
+ // no copied parameters/security/summary/tags at all.
194
+ const paths = doc?.paths;
195
+ if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) return false;
196
+ for (const pathItem of Object.values(paths)) {
197
+ if (typeof pathItem !== 'object' || pathItem === null || Array.isArray(pathItem)) continue;
198
+ for (const [methodKey, operation] of Object.entries(pathItem)) {
199
+ if (!HTTP_METHODS.has(methodKey.toLowerCase())) continue; // skip 'parameters'/'summary'/etc path-item-level keys
200
+ if (operation && typeof operation === 'object' && !Array.isArray(operation) && Object.hasOwn(operation, BSKEL_PASSTHROUGH_EXTENSION)) {
201
+ return true;
202
+ }
203
+ }
204
+ }
205
+ return false;
130
206
  }
131
207
 
132
208
  export function normalizeRoute(routePath) {
@@ -186,17 +262,24 @@ export function indexOpenApiDocument(doc) {
186
262
  const byOperationId = new Map();
187
263
  const byRoute = new Map();
188
264
  const componentSchemas = new Map();
265
+ // A7: Map<name, schemeNode> from doc.components.securitySchemes -- same "Map, never a plain
266
+ // object" reasoning as componentSchemas above, same COMPONENT_SCHEMA_NAME_RE whitelist (a
267
+ // security scheme name becomes an object key downstream, in the contract's own root-level
268
+ // sourceSecuritySchemes).
269
+ const securitySchemes = new Map();
189
270
  const stats = {
190
271
  path_count: 0, operation_count: 0, skipped_path_refs: 0, rejected_operation_ids: 0,
191
272
  component_schema_count: 0, rejected_component_schemas: 0,
273
+ security_scheme_count: 0, rejected_security_schemes: 0,
192
274
  };
193
275
 
194
276
  const openapiVersion = typeof doc.openapi === 'string' ? doc.openapi : null;
195
277
  const schemaDialectSupported = typeof openapiVersion === 'string' && /^3\.1(?:\.|$)/.test(openapiVersion);
196
278
 
197
- const rawComponentSchemas = doc.components && typeof doc.components === 'object' && !Array.isArray(doc.components)
198
- ? doc.components.schemas
279
+ const rawComponents = doc.components && typeof doc.components === 'object' && !Array.isArray(doc.components)
280
+ ? doc.components
199
281
  : null;
282
+ const rawComponentSchemas = rawComponents ? rawComponents.schemas : null;
200
283
  if (rawComponentSchemas && typeof rawComponentSchemas === 'object' && !Array.isArray(rawComponentSchemas)) {
201
284
  const schemaNames = Object.keys(rawComponentSchemas);
202
285
  if (schemaNames.length > MAX_COMPONENT_SCHEMAS) {
@@ -211,9 +294,24 @@ export function indexOpenApiDocument(doc) {
211
294
  stats.component_schema_count = componentSchemas.size;
212
295
  }
213
296
 
297
+ const rawSecuritySchemes = rawComponents ? rawComponents.securitySchemes : null;
298
+ if (rawSecuritySchemes && typeof rawSecuritySchemes === 'object' && !Array.isArray(rawSecuritySchemes)) {
299
+ const schemeNames = Object.keys(rawSecuritySchemes);
300
+ if (schemeNames.length > MAX_SECURITY_SCHEMES) {
301
+ return { ok: false, error: `OpenAPI document has ${schemeNames.length} security schemes, exceeds the ${MAX_SECURITY_SCHEMES}-scheme limit` };
302
+ }
303
+ for (const name of schemeNames) {
304
+ const value = rawSecuritySchemes[name];
305
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) continue;
306
+ if (!COMPONENT_SCHEMA_NAME_RE.test(name)) { stats.rejected_security_schemes++; continue; }
307
+ securitySchemes.set(name, value);
308
+ }
309
+ stats.security_scheme_count = securitySchemes.size;
310
+ }
311
+
214
312
  const paths = doc.paths;
215
313
  if (typeof paths !== 'object' || paths === null || Array.isArray(paths)) {
216
- return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers: [], openapiVersion, schemaDialectSupported };
314
+ return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, stats, servers: [], openapiVersion, schemaDialectSupported };
217
315
  }
218
316
 
219
317
  const pathKeys = Object.keys(paths);
@@ -266,7 +364,17 @@ export function indexOpenApiDocument(doc) {
266
364
  const responses = typeof operation.responses === 'object' && operation.responses !== null && !Array.isArray(operation.responses)
267
365
  ? operation.responses
268
366
  : null;
269
- const entry = { verb, path: routeKey, operationId, requestBody, responses };
367
+ // A7: raw parameters/security/summary/tags retained verbatim, same "no new read, no new
368
+ // size cap" reasoning as requestBody/responses above. `security` is deliberately
369
+ // Array.isArray-checked rather than truthy-checked -- a real, explicit `[]` (11/148 real
370
+ // operations) must be preserved as-is, not collapsed into "absent" the way a falsy check
371
+ // would (an empty array is truthy in JS, so this already works, but the explicit
372
+ // Array.isArray guard is what actually decides "present" vs "absent/malformed").
373
+ const parameters = Array.isArray(operation.parameters) ? operation.parameters : null;
374
+ const security = Array.isArray(operation.security) ? operation.security : null;
375
+ const summary = typeof operation.summary === 'string' ? operation.summary : null;
376
+ const tags = Array.isArray(operation.tags) ? operation.tags : null;
377
+ const entry = { verb, path: routeKey, operationId, requestBody, responses, parameters, security, summary, tags };
270
378
 
271
379
  const routeMatchKey = `${verb} ${normalizedRoute}`;
272
380
  const existingRoute = byRoute.get(routeMatchKey);
@@ -284,7 +392,7 @@ export function indexOpenApiDocument(doc) {
284
392
  ? doc.servers.filter((s) => s && typeof s.url === 'string').map((s) => s.url)
285
393
  : [];
286
394
 
287
- return { ok: true, byOperationId, byRoute, componentSchemas, stats, servers, openapiVersion, schemaDialectSupported };
395
+ return { ok: true, byOperationId, byRoute, componentSchemas, securitySchemes, stats, servers, openapiVersion, schemaDialectSupported };
288
396
  }
289
397
 
290
398
  // `S` (scan path) always starts with "/" (scanners/adapters/java-spring.mjs's joinPath guarantees
@@ -353,11 +461,14 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
353
461
 
354
462
  if (Object.hasOwn(node, '$ref')) {
355
463
  // 2020-12 permits siblings alongside $ref (unlike OpenAPI 3.0's restriction), but this
356
- // module doesn't attempt to MERGE $ref with a sibling assertion -- a DROPPED_KEYWORDS
357
- // sibling (e.g. a documentation-only `description`) is harmless and ignored; anything else
358
- // would need merge semantics this vertical slice doesn't implement, so it fails closed.
464
+ // module doesn't attempt to MERGE $ref with a sibling assertion, with ONE exception (A7):
465
+ // `default` is a real, human-authored override worth carrying through (the exact real shape
466
+ // `{"$ref": ".../ProjectListSort", "default": "READINESS"}` -- a $ref-typed parameter
467
+ // schema with its own default value, 9 real occurrences). A DROPPED_KEYWORDS sibling (e.g. a
468
+ // documentation-only `description`) is harmless and ignored; anything else would need merge
469
+ // semantics this vertical slice doesn't implement, so it fails closed.
359
470
  const siblingKeys = Object.keys(node).filter((k) => k !== '$ref');
360
- if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k))) fail('ref-with-siblings');
471
+ if (siblingKeys.some((k) => !DROPPED_KEYWORDS.has(k) && k !== 'default')) fail('ref-with-siblings');
361
472
  const ref = node['$ref'];
362
473
  if (typeof ref !== 'string' || !ref.startsWith(SCHEMA_REF_PREFIX)) fail('unsupported-ref');
363
474
  const name = ref.slice(SCHEMA_REF_PREFIX.length);
@@ -368,13 +479,20 @@ function walkSchemaNode(node, componentSchemas, depth, visiting, state, limits)
368
479
  const target = componentSchemas.get(name);
369
480
  if (!target) fail('component-not-found');
370
481
  visiting.add(name);
482
+ let resolved;
371
483
  try {
372
- return walkSchemaNode(target, componentSchemas, depth + 1, visiting, state, limits);
484
+ resolved = walkSchemaNode(target, componentSchemas, depth + 1, visiting, state, limits);
373
485
  } finally {
374
486
  // Delete-on-exit: a diamond (two sibling properties referencing the SAME component) stays
375
487
  // legal and is inlined independently for each -- only a true ancestor-chain cycle fails.
376
488
  visiting.delete(name);
377
489
  }
490
+ // A7: the sibling `default` (if any) is merged into the RESOLVED schema, not the $ref node
491
+ // itself -- it describes what value applies when this particular use of the component is
492
+ // absent, which is a fact about the reference site, carried onto the schema the reference
493
+ // resolves to.
494
+ if (Object.hasOwn(node, 'default')) resolved.default = node.default;
495
+ return resolved;
378
496
  }
379
497
 
380
498
  const out = {};
@@ -626,6 +744,381 @@ function applyProjectionOutcome(result, projected, stats, kind) {
626
744
  }
627
745
  }
628
746
 
747
+ // ===== A7: source-backed OpenAPI field passthrough =====
748
+ // D-openapi-passthrough: a field may be copied iff its value exists verbatim in the source
749
+ // document, on the operation object reconciliation already tied to this contract operation, and
750
+ // the only transformation applied is one this module already performs mechanically elsewhere
751
+ // (inlineSchema()'s $ref inlining + keyword whitelist + format:uuid rewrite). Anything requiring a
752
+ // decision about what the API probably does stays omitted. A7 (Phase 1): non-path parameters,
753
+ // security, summary, tags. A8 (D-openapi-per-status, below): per-status responses, non-JSON
754
+ // request media types. Operation-level `description` remains excluded (measured too expensive to
755
+ // default-on -- see D-openapi-per-status).
756
+
757
+ // A7: `in` locations this passthrough may copy. 'path' is deliberately excluded -- path parameters
758
+ // stay contract-derived from the route template + the contract's own name heuristic (see
759
+ // contracts/export.mjs's buildPathParameters), a genuinely separate kind of claim from a copied
760
+ // source parameter, disclosed as its own `path-parameter-schemas` omission entry rather than
761
+ // silently superseded here.
762
+ const PARAMETER_LOCATIONS = Object.freeze(new Set(['query', 'header', 'cookie']));
763
+
764
+ // A7: Parameter Object key policy -- mirrors inlineSchema()'s RECURSED/COPIED/DROPPED/fail-closed
765
+ // shape one level up (a Parameter Object, not a Schema Object). COPIED verbatim (every value here
766
+ // is a scalar, nothing to recurse into); `schema` is RESOLVED through inlineSchema() (handled
767
+ // separately in copyParameter, below); `example`/`examples` are DROPPED (annotation-only, same
768
+ // reasoning DROPPED_KEYWORDS already applies one layer down); a `$ref` parameter (needs
769
+ // components.parameters, which indexOpenApiDocument() does not index), a `content`-keyed parameter
770
+ // (a media-type-keyed alternative to `schema` -- Phase 2 machinery), or any other unrecognized key
771
+ // fails CLOSED for that ONE parameter only, never the whole operation.
772
+ const PARAMETER_COPIED_KEYS = Object.freeze(new Set([
773
+ 'required', 'deprecated', 'description', 'style', 'explode', 'allowReserved', 'allowEmptyValue',
774
+ ]));
775
+ const PARAMETER_DROPPED_KEYS = Object.freeze(new Set(['example', 'examples']));
776
+
777
+ function safeParamName(raw) {
778
+ return raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.name === 'string' ? raw.name : null;
779
+ }
780
+ function safeParamIn(raw) {
781
+ return raw && typeof raw === 'object' && !Array.isArray(raw) && typeof raw.in === 'string' ? raw.in : null;
782
+ }
783
+
784
+ // Non-path parameters only, source order, deduplicated on (name,in) -- first occurrence wins, same
785
+ // "first occurrence wins" convention indexOpenApiDocument()'s own byOperationId uses. A malformed
786
+ // entry (not an object, or missing name/in as strings) cannot participate in dedup meaningfully --
787
+ // it is still counted and still attempted (and will fail its own copy attempt on its own merits).
788
+ function collectNonPathParameters(rawParameters) {
789
+ if (!Array.isArray(rawParameters)) return [];
790
+ const seen = new Set();
791
+ const out = [];
792
+ for (const p of rawParameters) {
793
+ if (p && typeof p === 'object' && !Array.isArray(p) && p.in === 'path') continue; // stays contract-derived
794
+ const name = safeParamName(p);
795
+ const loc = safeParamIn(p);
796
+ if (name !== null && loc !== null) {
797
+ const key = `${loc}${name}`;
798
+ if (seen.has(key)) continue;
799
+ seen.add(key);
800
+ }
801
+ out.push(p);
802
+ }
803
+ return out;
804
+ }
805
+
806
+ // Copies one raw Parameter Object into the sourceParameters shape, or fails it closed.
807
+ // Returns one of:
808
+ // {ok:false, reason} -- dropped entirely, not copied at all
809
+ // {ok:true, parameter} -- copied cleanly, schema included if present
810
+ // {ok:true, parameter, schemaUnresolvedReason} -- copied, but `schema` could not resolve
811
+ // The middle+last cases both add the parameter to sourceParameters (every OTHER field it carries is
812
+ // real and safe); the last case additionally drives CONTRACT_OPENAPI_PARAMETERS_UNRESOLVED, exactly
813
+ // the "found but couldn't project" distinction applyRequestBodySchema already draws for a body.
814
+ function copyParameter(raw, componentSchemas) {
815
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, reason: 'not-a-parameter-object' };
816
+ if (Object.hasOwn(raw, '$ref')) return { ok: false, reason: 'ref-parameter' };
817
+ if (Object.hasOwn(raw, 'content')) return { ok: false, reason: 'content-parameter' };
818
+ const name = raw.name;
819
+ if (typeof name !== 'string' || name.length === 0) return { ok: false, reason: 'missing-name' };
820
+ const loc = raw.in;
821
+ if (!PARAMETER_LOCATIONS.has(loc)) return { ok: false, reason: 'unsupported-location' };
822
+
823
+ const parameter = { name, in: loc };
824
+ for (const key of Object.keys(raw)) {
825
+ if (key === 'name' || key === 'in' || key === 'schema') continue; // handled explicitly
826
+ if (PARAMETER_DROPPED_KEYS.has(key)) continue;
827
+ if (PARAMETER_COPIED_KEYS.has(key)) { parameter[key] = raw[key]; continue; }
828
+ return { ok: false, reason: `unsupported-keyword:${key}` };
829
+ }
830
+
831
+ if (Object.hasOwn(raw, 'schema')) {
832
+ const resolved = inlineSchema(raw.schema, componentSchemas);
833
+ if (resolved.ok) {
834
+ parameter.schema = resolved.schema;
835
+ } else {
836
+ return { ok: true, parameter, schemaUnresolvedReason: resolved.reason };
837
+ }
838
+ }
839
+ return { ok: true, parameter };
840
+ }
841
+
842
+ // A7: parameters ride the schema-bearing gate (schemaProjectionEnabled) -- a `schema` inside a
843
+ // parameter goes through inlineSchema() exactly like a request/response body does, and a 3.0
844
+ // document's `exclusiveMinimum`/`nullable` mean something different under the 2020-12 dialect
845
+ // Ajv2020 speaks, the same reasoning that already disables A2/A3 for a whole document. Sets, on
846
+ // `result`: parametersTotal/parametersCleanCount (reconciliation-internal bookkeeping consumed only
847
+ // by snapshotFromReconciliation, never copied into the contract itself), parametersSkippedDialect,
848
+ // sourceParameters (omitted when empty), parametersUnresolved (omitted when empty).
849
+ function applyParameters(result, docEntry, index, stats, schemaProjectionEnabled) {
850
+ const candidates = collectNonPathParameters(docEntry.parameters);
851
+ if (candidates.length === 0) {
852
+ stats.parameters_none++;
853
+ return;
854
+ }
855
+ if (!schemaProjectionEnabled) {
856
+ result.parametersSkippedDialect = true;
857
+ stats.parameters_skipped_dialect++;
858
+ return;
859
+ }
860
+
861
+ const copied = [];
862
+ const unresolved = [];
863
+ let cleanCount = 0;
864
+
865
+ if (candidates.length > MAX_PARAMETERS_PER_OPERATION) {
866
+ // Exceeding the cap fails the WHOLE parameter set closed for this operation, never the whole
867
+ // reconciliation -- same "fail this field, not the operation kind" posture every cap in this
868
+ // module already takes.
869
+ for (const p of candidates) {
870
+ unresolved.push({ name: safeParamName(p), in: safeParamIn(p), reason: 'too-many-parameters' });
871
+ }
872
+ } else {
873
+ for (const raw of candidates) {
874
+ const outcome = copyParameter(raw, index.componentSchemas);
875
+ if (!outcome.ok) {
876
+ unresolved.push({ name: safeParamName(raw), in: safeParamIn(raw), reason: outcome.reason });
877
+ continue;
878
+ }
879
+ copied.push(outcome.parameter);
880
+ if (outcome.schemaUnresolvedReason) {
881
+ unresolved.push({ name: outcome.parameter.name, in: outcome.parameter.in, reason: outcome.schemaUnresolvedReason });
882
+ } else {
883
+ cleanCount++;
884
+ }
885
+ }
886
+ }
887
+
888
+ result.parametersTotal = candidates.length;
889
+ result.parametersCleanCount = cleanCount;
890
+ if (copied.length > 0) result.sourceParameters = copied;
891
+ if (unresolved.length > 0) result.parametersUnresolved = unresolved;
892
+
893
+ if (cleanCount === candidates.length) stats.parameters_copied++;
894
+ else stats.parameters_unresolved++;
895
+ }
896
+
897
+ // A7: security is dialect-independent -- a Security Requirement Object means the same thing under
898
+ // 3.0 and 3.1, so this is copied regardless of schemaProjectionEnabled (a deliberate asymmetry with
899
+ // applyParameters above, not an oversight -- see D-openapi-passthrough). All-or-nothing per
900
+ // operation (unlike parameters' per-item fail-closed): a requirement naming a scheme that could not
901
+ // be resolved means the WHOLE security value is dropped for that operation, never a dangling
902
+ // reference -- verified by executing the real 3.1 meta-schema that it cannot itself catch this (a
903
+ // `security` requirement naming an undeclared scheme validates `true`). `referencedSchemeNames` is
904
+ // a Set shared across the whole reconciliation -- accumulates every scheme name any operation's
905
+ // COPIED security actually referenced, which becomes the contract-root `sourceSecuritySchemes`.
906
+ function applySecurity(result, docEntry, index, stats, referencedSchemeNames) {
907
+ const security = docEntry.security;
908
+ if (security === null) {
909
+ stats.security_none++;
910
+ return;
911
+ }
912
+ if (security.length > MAX_SECURITY_REQUIREMENTS_PER_OPERATION) {
913
+ result.securityUnresolvedReason = 'too-many-requirements';
914
+ stats.security_unresolved++;
915
+ return;
916
+ }
917
+ const namesInThisOperation = [];
918
+ for (const requirement of security) {
919
+ if (!requirement || typeof requirement !== 'object' || Array.isArray(requirement)) {
920
+ result.securityUnresolvedReason = 'malformed-requirement';
921
+ stats.security_unresolved++;
922
+ return;
923
+ }
924
+ for (const schemeName of Object.keys(requirement)) {
925
+ if (!Object.hasOwn(requirement, schemeName)) continue; // Object.keys is already own-only; defensive
926
+ if (!index.securitySchemes.has(schemeName)) {
927
+ result.securityUnresolvedReason = 'unknown-scheme';
928
+ stats.security_unresolved++;
929
+ return;
930
+ }
931
+ namesInThisOperation.push(schemeName);
932
+ }
933
+ }
934
+ result.sourceSecurity = security;
935
+ for (const name of namesInThisOperation) referencedSchemeNames.add(name);
936
+ if (security.length === 0) stats.security_public++;
937
+ else stats.security_copied++;
938
+ }
939
+
940
+ // A7: summary/tags are dialect-independent, same reasoning as applySecurity above -- copied
941
+ // verbatim, never synthesized from operationId or module names (same discipline A6's own omitted-
942
+ // summaries rule established, now source-conditioned instead of always-omitted).
943
+ function applySummaryAndTags(result, docEntry, stats) {
944
+ if (typeof docEntry.summary === 'string' && docEntry.summary.length > 0) {
945
+ result.sourceSummary = docEntry.summary;
946
+ stats.summary_copied++;
947
+ }
948
+ if (Array.isArray(docEntry.tags) && docEntry.tags.length > 0) {
949
+ const tags = docEntry.tags.filter((t) => typeof t === 'string');
950
+ if (tags.length > 0) {
951
+ result.sourceTags = tags;
952
+ stats.tags_copied++;
953
+ }
954
+ }
955
+ }
956
+
957
+ // A8: per-status responses -- additive to (never replacing) the responseSchema/errorSchema union
958
+ // projection above; contracts/validate.mjs is untouched by this item, see D-openapi-per-status.
959
+ // Gated on schemaProjectionEnabled, same reasoning as applyParameters -- a JSON Schema resolved
960
+ // under a 3.0 document's dialect means something different under 2020-12, and per-status
961
+ // responses resolve schemas via the exact same inlineSchema() path parameters/bodies already do.
962
+ //
963
+ // Written only when BOTH projectResponseSchemas() outcomes for this operation are resolved-or-none
964
+ // (never unresolved) -- if either bucket failed to resolve, this silently and correctly skips,
965
+ // falling back to the existing union-only export path; the failure is already recorded via A3's
966
+ // own responseSchemaUnresolvedReason/errorSchemaUnresolvedReason, nothing here duplicates it.
967
+ //
968
+ // schemaFrom is provable, not guessed: projectResponseSchemas() already deduplicated every status
969
+ // in its bucket down to `sources` distinct resolved shapes -- if sources===1, EVERY status in that
970
+ // bucket with a JSON schema resolved to THAT one shape, by construction, so this function can point
971
+ // at it instead of re-resolving. When sources>1 (never observed on real data, but structurally
972
+ // possible) or the status sits outside both buckets (a 1xx/3xx key), the status's own schema is
973
+ // resolved individually into an inline `schema` instead.
974
+ function applyPerStatusResponses(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
975
+ if (!schemaProjectionEnabled) {
976
+ result.perStatusResponsesSkippedDialect = true;
977
+ stats.per_status_skipped_dialect++;
978
+ return;
979
+ }
980
+ const responses = docEntry.responses;
981
+ if (!responses || typeof responses !== 'object' || Array.isArray(responses) || Object.keys(responses).length === 0) {
982
+ stats.per_status_none++;
983
+ return;
984
+ }
985
+ if (result.responseSchemaUnresolvedReason || result.errorSchemaUnresolvedReason) {
986
+ result.perStatusResponsesSkippedUnresolved = true;
987
+ stats.per_status_skipped_unresolved++;
988
+ return;
989
+ }
990
+
991
+ const perStatus = {};
992
+ let anyEntry = false;
993
+ for (const key of Object.keys(responses)) {
994
+ if (!RESPONSE_STATUS_KEY_RE.test(key)) continue; // not a legal status key -- dropped, not a failure
995
+ const resp = responses[key];
996
+ if (typeof resp !== 'object' || resp === null || Array.isArray(resp)) continue;
997
+ // A8 follow-up (Codex review): a Response Object `$ref` (`components.responses.<Name>`, legal
998
+ // per the official 3.1 meta-schema's `response-or-reference`) is not resolved here -- 0 real
999
+ // occurrences against the Team-IZ-Backend oracle (694 response objects, 0 $ref), named rather
1000
+ // than built, same "don't build for zero real cases" discipline as non-json-response-schemas/
1001
+ // response-headers below. Skipping is the fail-closed choice: falling through with
1002
+ // resp.description/resp.content both undefined would produce a description-only entry carrying
1003
+ // the synthetic PER_STATUS_NO_DESCRIPTION_STANDIN as if the source truly documented this status
1004
+ // with no description -- false. A referenced response is simply omitted for this status.
1005
+ if (typeof resp.$ref === 'string') continue;
1006
+
1007
+ const entry = {};
1008
+ if (typeof resp.description === 'string' && resp.description.length > 0 && resp.description !== PER_STATUS_NO_DESCRIPTION_STANDIN) {
1009
+ entry.description = resp.description;
1010
+ }
1011
+
1012
+ const content = resp.content;
1013
+ if (content && typeof content === 'object' && !Array.isArray(content)) {
1014
+ if (Object.hasOwn(content, JSON_MEDIA_TYPE)) {
1015
+ const mediaEntry = content[JSON_MEDIA_TYPE];
1016
+ const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
1017
+ if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
1018
+ if (SUCCESS_STATUS_RE.test(key) && result.responseSchema && result.responseSchemaSources === 1) {
1019
+ entry.schemaFrom = 'response';
1020
+ } else if ((ERROR_STATUS_RE.test(key) || key === DEFAULT_STATUS_KEY) && result.errorSchema && result.errorSchemaSources === 1) {
1021
+ entry.schemaFrom = 'error';
1022
+ } else {
1023
+ const resolved = inlineSchema(schemaNode, componentSchemas);
1024
+ // unresolved here just leaves `schema` absent -- `description` alone (if any) stays
1025
+ // valid, same "copied without schema" posture copyParameter() already takes.
1026
+ if (resolved.ok) entry.schema = resolved.schema;
1027
+ }
1028
+ }
1029
+ } else {
1030
+ const mediaTypes = Object.keys(content).filter((mt) => MEDIA_TYPE_RE.test(mt));
1031
+ if (mediaTypes.length > 0) entry.mediaTypes = mediaTypes;
1032
+ }
1033
+ }
1034
+
1035
+ perStatus[key] = entry;
1036
+ anyEntry = true;
1037
+ }
1038
+
1039
+ if (!anyEntry) {
1040
+ stats.per_status_none++;
1041
+ return;
1042
+ }
1043
+ result.sourceResponses = perStatus;
1044
+ stats.per_status_copied++;
1045
+ }
1046
+
1047
+ // A8: non-JSON request media types (multipart/form-data in the real oracle) -- additive to A2's
1048
+ // requestBodySchema, never overlapping it: `content` here never contains application/json (that
1049
+ // stays A2's field, the one representation of that one fact -- expressed as a schema invariant in
1050
+ // schemas/feature-contract.schema.json too). Gated on schemaProjectionEnabled for the same reason
1051
+ // as applyPerStatusResponses above -- a media-type schema resolves through the same inlineSchema()
1052
+ // path.
1053
+ function applyRequestMediaTypes(result, docEntry, componentSchemas, stats, schemaProjectionEnabled) {
1054
+ if (!schemaProjectionEnabled) {
1055
+ result.requestMediaTypesSkippedDialect = true;
1056
+ stats.request_media_types_skipped_dialect++;
1057
+ return;
1058
+ }
1059
+ const requestBody = docEntry.requestBody;
1060
+ if (!requestBody || typeof requestBody !== 'object' || Array.isArray(requestBody) || Object.hasOwn(requestBody, '$ref')) {
1061
+ stats.request_media_types_none++;
1062
+ return;
1063
+ }
1064
+ const content = requestBody.content;
1065
+ if (typeof content !== 'object' || content === null || Array.isArray(content)) {
1066
+ stats.request_media_types_none++;
1067
+ return;
1068
+ }
1069
+ const mediaTypeKeys = Object.keys(content).filter((mt) => mt !== JSON_MEDIA_TYPE && MEDIA_TYPE_RE.test(mt));
1070
+ if (mediaTypeKeys.length === 0) {
1071
+ stats.request_media_types_none++;
1072
+ return;
1073
+ }
1074
+ if (mediaTypeKeys.length > MAX_REQUEST_MEDIA_TYPES) {
1075
+ // Exceeding the cap fails the WHOLE media-type set closed for this operation, same "fail this
1076
+ // field, not the operation kind" posture every cap in this module already takes.
1077
+ result.requestMediaTypesUnresolvedReason = 'too-many-media-types';
1078
+ stats.request_media_types_unresolved++;
1079
+ return;
1080
+ }
1081
+
1082
+ const copiedContent = {};
1083
+ let cleanCount = 0;
1084
+ for (const mt of mediaTypeKeys) {
1085
+ const mediaEntry = content[mt];
1086
+ const entry = {};
1087
+ const schemaNode = mediaEntry && typeof mediaEntry === 'object' && !Array.isArray(mediaEntry) ? mediaEntry.schema : null;
1088
+ if (schemaNode && typeof schemaNode === 'object' && !Array.isArray(schemaNode)) {
1089
+ const resolved = inlineSchema(schemaNode, componentSchemas);
1090
+ if (resolved.ok) { entry.schema = resolved.schema; cleanCount++; }
1091
+ } else {
1092
+ cleanCount++; // no schema declared for this media type at all is not a failure -- same
1093
+ // "nothing to project" posture applyRequestBodySchema already takes.
1094
+ }
1095
+ copiedContent[mt] = entry;
1096
+ }
1097
+
1098
+ result.sourceRequestBody = { content: copiedContent };
1099
+ if (requestBody.required === true) result.sourceRequestBody.required = true;
1100
+ if (cleanCount === mediaTypeKeys.length) {
1101
+ stats.request_media_types_copied++;
1102
+ } else {
1103
+ result.requestMediaTypesUnresolvedReason = 'schema-unresolved';
1104
+ stats.request_media_types_unresolved++;
1105
+ }
1106
+ }
1107
+
1108
+ // A7: the single entry point called from reconcileModule()'s two matched/adopted call sites --
1109
+ // exactly the placement A2/A3's own helpers already occupy, which IS the refusal mechanism for
1110
+ // every other resolution kind (drift/missing/ambiguous/unresolved never reach this function at
1111
+ // all, see reconcileModule below).
1112
+ function applyPassthrough(result, docEntry, index, stats, schemaProjectionEnabled, referencedSchemeNames) {
1113
+ applyParameters(result, docEntry, index, stats, schemaProjectionEnabled);
1114
+ applySecurity(result, docEntry, index, stats, referencedSchemeNames);
1115
+ applySummaryAndTags(result, docEntry, stats);
1116
+ // A8: same matched/adopted-only placement as the three calls above -- this IS the refusal
1117
+ // mechanism for every other resolution kind, extended unchanged for the two new fields.
1118
+ applyPerStatusResponses(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled);
1119
+ applyRequestMediaTypes(result, docEntry, index.componentSchemas, stats, schemaProjectionEnabled);
1120
+ }
1121
+
629
1122
  // The core reconciliation, pure (no I/O). `module` is a scanReport related_modules entry (as
630
1123
  // selected by contracts/emit.mjs's selectModule -- caller's responsibility to pass the SAME
631
1124
  // selection buildContract() will use, so endpointKey(ci,ei) lines up). `pathPrefix`, if given
@@ -660,7 +1153,22 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
660
1153
  // A3: response (2xx) / error (4xx/5xx) counters, same stable-shape reasoning.
661
1154
  response_schema_resolved: 0, response_schema_unresolved: 0, response_schema_none: 0, response_schema_skipped_media_type: 0,
662
1155
  error_schema_resolved: 0, error_schema_unresolved: 0, error_schema_none: 0, error_schema_skipped_media_type: 0,
1156
+ // A7: source-backed passthrough counters -- parameters/security are per-OPERATION tallies
1157
+ // (an operation with a mix of resolved/unresolved parameters counts once, under
1158
+ // parameters_unresolved, matching the "partial:M-of-N" snapshot decision), summary/tags are
1159
+ // simple presence counts.
1160
+ parameters_copied: 0, parameters_unresolved: 0, parameters_none: 0, parameters_skipped_dialect: 0,
1161
+ security_copied: 0, security_public: 0, security_unresolved: 0, security_none: 0,
1162
+ summary_copied: 0, tags_copied: 0,
1163
+ // A8: per-status responses / request media types counters, same per-operation-tally shape as
1164
+ // the A7 counters above.
1165
+ per_status_copied: 0, per_status_skipped_unresolved: 0, per_status_none: 0, per_status_skipped_dialect: 0,
1166
+ request_media_types_copied: 0, request_media_types_unresolved: 0, request_media_types_none: 0, request_media_types_skipped_dialect: 0,
663
1167
  };
1168
+ // A7: accumulates every security-scheme name any operation's COPIED security requirement
1169
+ // actually referenced, across the WHOLE module -- becomes the contract-root sourceSecuritySchemes
1170
+ // (only schemes actually used, never the document's full componentSchemas-style catalog).
1171
+ const referencedSecuritySchemeNames = new Set();
664
1172
  // A2: an OpenAPI 3.0 document's `exclusiveMinimum`/`nullable` mean something different under
665
1173
  // JSON Schema 2020-12 (the dialect Ajv2020 speaks) -- rather than silently misinterpreting
666
1174
  // those, schema projection is disabled for the WHOLE document, once, here -- not per-operation
@@ -701,6 +1209,11 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
701
1209
  applyRequestBodySchema(result, docEntry, index.componentSchemas, stats);
702
1210
  applyResponseSchemas(result, docEntry, index.componentSchemas, stats);
703
1211
  }
1212
+ // A7: same matched/adopted-only placement -- this IS the refusal mechanism for
1213
+ // every other resolution kind. Called unconditionally (not gated on
1214
+ // schemaProjection.enabled): parameters gate internally (schema-bearing);
1215
+ // security/summary/tags are dialect-independent and always attempted.
1216
+ applyPassthrough(result, docEntry, index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
704
1217
  } else {
705
1218
  result = {
706
1219
  kind: 'drift', reason: 'path',
@@ -729,6 +1242,7 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
729
1242
  applyRequestBodySchema(result, hits[0], index.componentSchemas, stats);
730
1243
  applyResponseSchemas(result, hits[0], index.componentSchemas, stats);
731
1244
  }
1245
+ applyPassthrough(result, hits[0], index, stats, schemaProjection.enabled, referencedSecuritySchemeNames);
732
1246
  } else if (hits.length === 1) {
733
1247
  // A single route match, but the document itself never gave that operation an
734
1248
  // operationId -- nothing to route by, so this can't become an addressable
@@ -749,7 +1263,16 @@ export function reconcileModule({ index, module, pathPrefix = null }) {
749
1263
  }
750
1264
  }
751
1265
 
752
- return { byEndpoint, prefix, stats, schemaProjection };
1266
+ // A7: resolve the accumulated scheme names against index.securitySchemes -- only names that
1267
+ // actually made it into at least one operation's COPIED sourceSecurity end up here, so this Map
1268
+ // is exactly what buildContract() should attach as the contract-root sourceSecuritySchemes.
1269
+ const sourceSecuritySchemes = new Map();
1270
+ for (const name of referencedSecuritySchemeNames) {
1271
+ const scheme = index.securitySchemes.get(name);
1272
+ if (scheme) sourceSecuritySchemes.set(name, scheme);
1273
+ }
1274
+
1275
+ return { byEndpoint, prefix, stats, schemaProjection, sourceSecuritySchemes };
753
1276
  }
754
1277
 
755
1278
  // Convenience entry point: load + index + reconcile in one call, propagating the first failure.
@@ -790,6 +1313,8 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
790
1313
  rejected_operation_ids: indexed.stats.rejected_operation_ids,
791
1314
  component_schema_count: indexed.stats.component_schema_count,
792
1315
  rejected_component_schemas: indexed.stats.rejected_component_schemas,
1316
+ security_scheme_count: indexed.stats.security_scheme_count,
1317
+ rejected_security_schemes: indexed.stats.rejected_security_schemes,
793
1318
  openapi_version: indexed.openapiVersion,
794
1319
  servers: indexed.servers,
795
1320
  },
@@ -797,9 +1322,50 @@ export function buildReconciliation({ filePath, module, pathPrefix = null }) {
797
1322
  prefix: recon.prefix,
798
1323
  stats: recon.stats,
799
1324
  schemaProjection: recon.schemaProjection,
1325
+ // A7: only schemes actually referenced by at least one copied sourceSecurity requirement --
1326
+ // contracts/emit.mjs attaches this at the contract root when non-empty.
1327
+ sourceSecuritySchemes: recon.sourceSecuritySchemes,
800
1328
  };
801
1329
  }
802
1330
 
1331
+ // A7: decision-only audit-trail strings for the four passthrough fields, exactly parallel to
1332
+ // request_body_schema/response_schema/error_schema above -- the snapshot records the DECISION, not
1333
+ // the payload (the payload lives in the contract file, already covered by contract_hash).
1334
+ function parametersDecision(result) {
1335
+ if (result.parametersSkippedDialect) return 'skipped:dialect';
1336
+ if (!result.parametersTotal) return 'none';
1337
+ return result.parametersCleanCount === result.parametersTotal
1338
+ ? `copied:${result.parametersTotal}`
1339
+ : `partial:${result.parametersCleanCount}-of-${result.parametersTotal}`;
1340
+ }
1341
+ function securityDecision(result) {
1342
+ if (result.sourceSecurity) return result.sourceSecurity.length === 0 ? 'copied:public' : `copied:${result.sourceSecurity.length}`;
1343
+ if (result.securityUnresolvedReason) return `unresolved:${result.securityUnresolvedReason}`;
1344
+ return 'none';
1345
+ }
1346
+ function summaryDecision(result) {
1347
+ return result.sourceSummary ? 'copied' : 'none';
1348
+ }
1349
+ function tagsDecision(result) {
1350
+ return result.sourceTags ? `copied:${result.sourceTags.length}` : 'none';
1351
+ }
1352
+ // A8: same decision-only audit-trail shape, for the two new passthrough fields.
1353
+ function perStatusResponsesDecision(result) {
1354
+ if (result.perStatusResponsesSkippedDialect) return 'skipped:dialect';
1355
+ if (result.perStatusResponsesSkippedUnresolved) return 'skipped:unresolved';
1356
+ if (result.sourceResponses) return `copied:${Object.keys(result.sourceResponses).length}`;
1357
+ return 'none';
1358
+ }
1359
+ function requestMediaTypesDecision(result) {
1360
+ if (result.requestMediaTypesSkippedDialect) return 'skipped:dialect';
1361
+ if (result.sourceRequestBody) {
1362
+ const count = Object.keys(result.sourceRequestBody.content).length;
1363
+ return result.requestMediaTypesUnresolvedReason ? `partial:${count}` : `copied:${count}`;
1364
+ }
1365
+ if (result.requestMediaTypesUnresolvedReason) return `unresolved:${result.requestMediaTypesUnresolvedReason}`;
1366
+ return 'none';
1367
+ }
1368
+
803
1369
  // `sourceFile`: {file, outsideRepo} precomputed by the caller (bin/bskel.mjs knows the repo
804
1370
  // root; this module deliberately doesn't) -- keeps machine-specific absolute paths out of a
805
1371
  // committed artifact when the OpenAPI file lives outside the repo.
@@ -831,6 +1397,14 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
831
1397
  : result.errorSchemaUnresolvedReason
832
1398
  ? `unresolved:${result.errorSchemaUnresolvedReason}`
833
1399
  : 'none',
1400
+ // A7: same decision-only audit trail, for the four passthrough fields.
1401
+ parameters: parametersDecision(result),
1402
+ security: securityDecision(result),
1403
+ summary: summaryDecision(result),
1404
+ tags: tagsDecision(result),
1405
+ // A8: same decision-only audit trail, for the two new passthrough fields.
1406
+ per_status_responses: perStatusResponsesDecision(result),
1407
+ request_media_types: requestMediaTypesDecision(result),
834
1408
  };
835
1409
  }
836
1410
  }
@@ -849,6 +1423,8 @@ export function snapshotFromReconciliation(reconciliation, { featureId, sourceFi
849
1423
  skipped_path_refs: reconciliation.document.skipped_path_refs,
850
1424
  component_schema_count: reconciliation.document.component_schema_count,
851
1425
  rejected_component_schemas: reconciliation.document.rejected_component_schemas,
1426
+ security_scheme_count: reconciliation.document.security_scheme_count,
1427
+ rejected_security_schemes: reconciliation.document.rejected_security_schemes,
852
1428
  openapi_version: reconciliation.document.openapi_version,
853
1429
  servers: reconciliation.document.servers,
854
1430
  },