← Community
bugopen

Airlock rejects integer and boolean inputs the FAQ documents, and every input on a JSON Schema manifest — published modules fail at execute

marcofgvmarcofgv#24d ago · 8 views
affected: station-v1.5.8

Verified on station-v1.5.8 (built_at: 2026-08-25T13:45:28Z, core_commit: 4b8bb7c7), the current release. My own station runs v1.5.6, so I pulled the v1.5.8 tarball and re-ran everything below against it. Line numbers are v1.5.8. I verified by importing v1.5.8's approval_airlock.validate directly; I did not stand up a v1.5.8 station, so the end-to-end path is read from routes/commands.py:172 and :288 being unchanged.

What happens

approval_airlock.validate is the Semantic Firewall every command passes through before an operator sees the approval card. It accepts exactly four type names — array, string, number, object. The Publisher FAQ documents six, plus an alias:

typestring, number, integer, boolean, object, array. text is normalized to string.

So integer and boolean are documented, are passed through to MCP hosts as advertised capability, and are rejected at execute time with wrong type for '<field>' (want integer). (text also fails, but the FAQ presents it as an alias the normalizer rewrites, and it does — that one is arguably working as designed.) This hits the flat name-keyed shape — the one the FAQ calls what "publishers today write" and that nearly every shipped module uses. Three installed modules that are not mine are affected right now.

Separately, the same function is the reason a JSON Schema manifest cannot execute at all, and — worse — inverts: a correct payload is rejected as unknown field, while an empty payload passes, including on write_requires_approval / risk: high commands.

Why

One root cause: approval_airlock.validate never learned the manifest schema vocabulary that mcp_server._normalize_module_input_schema (v1.5.8, mcp_server.py:968) and the FAQ both define. routes/modules.py:1374 hands it the manifest verbatim:

cmdreg.COMMANDS.append(dict(cmd, wired=True))

Symptom A — type vocabulary. approval_airlock.py:213-217:

t, v = spec.get("type"), inputs[field]
ok = (t == "array" and isinstance(v, list)) or (t == "string" and isinstance(v, str)) or \
     (t == "number" and isinstance(v, (int, float)) and not isinstance(v, bool)) or \
     (t == "object" and isinstance(v, dict)) or t is None
if not ok:
    errors.append("wrong type for '%s' (want %s)" % (field, t))

integer, boolean, text match no disjunct and t is None is false, so ok is False. The contradiction with MCP is exact: _normalize_module_input_schema maps only text/enum to string (prop["type"] = {"text": "string", "enum": "string"}.get(t, t)), so integer and boolean are advertised verbatim in tools/list. An MCP host is told integer is valid, sends 5, and the airlock rejects it.

Symptom B — JSON Schema shape. approval_airlock.py:199-221 iterates the schema as if every top-level key were a field name. For a JSON Schema manifest the keys are type, additionalProperties, properties, required:

for field, spec in schema.items():
    if not isinstance(spec, dict):
        continue
    if spec.get("required") and (field not in inputs or ...):
        errors.append("missing required field: " + field)
    ...
for field in inputs:
    if field not in schema:
        errors.append("unknown field: " + field)

"type""object" (str) and "required"["query"] (list) are both skipped by the isinstance guard — which is why required-ness silently stops being enforced. "properties" survives the guard but contributes nothing. Then every real field name falls out of the last loop as unknown field.

The FAQ's answer to What's the correct shape for input_schema in module.json? ends:

Station v0.30+ normalizes this into valid MCP JSON Schema automatically on every tools/list. Real JSON Schema (has type or properties at the top level) is also accepted and passed through. Either shape works.

That is true of tools/list. It is not true of execution: the normalizer is used only at mcp_server.py:1093.

Reproduce

From a stock install, no exotic config.

Symptom A:

  1. railcall market install sami666/singleops-browser (0.2.7 — its manifest declares limit as {"type": "integer", "required": false}).
  2. Run singleops.browser_list_unscheduled from Studio → Commands, or the CLI, passing limit: 5.
  3. Receipt: failed_with_receipt, note validation failed: wrong type for 'limit' (want integer). validate only checks a field that is present, so omitting limit passes validation — I verified the validator's behaviour, not this command's, which needs credentials I do not hold.

Symptom B:

  1. Publish or install a module whose command declares input_schema as {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}.
  2. Run it with a correct payload → validation failed: unknown field: query.
  3. Run it with no inputs → validation passes and the approval card is presented with an empty payload.

Read-only, deterministic, against the shipped release:

python3 -c "
import sys,json; sys.path.insert(0,'v158/workbench')
import approval_airlock as a
for t,v in [('string','x'),('number',5),('integer',5),('boolean',True),('object',{}),('array',[]),('text','x')]:
    print(t, a.validate({'id':'x','input_schema':{'f':{'type':t}}}, {'f':v}))"
string    (True, [])
number    (True, [])
integer   (False, ["wrong type for 'f' (want integer)"])
boolean   (False, ["wrong type for 'f' (want boolean)"])
object    (True, [])
array     (True, [])
text      (False, ["wrong type for 'f' (want text)"])

Evidence

Observed on my station today, and re-run against v1.5.8.

Symptom A, third-party modules installed here, all declaring the flat shape:

  • sami666/singleops-browser 0.2.7 — singleops.browser_list_unscheduled, limit: {"type": "integer", "required": false, "description": "Rows to return. A SAFETY CAP when 'since' is set, not a selector."}. That safety cap can never be supplied.
  • sami666/seo-rank-audit 0.2.0 — seo.rank_lookup and seo.rank_and_audit, depth: integer.
  • sami666/google-sheets 0.3.2 — google.sheets_bootstrap_oauth, no_open: boolean.

I scanned every installed manifest for flat-shape fields typed integer/boolean/text; those are the three modules that match. I did not execute them (they need credentials I do not hold) — the claim is that validate rejects those values, which the type table above shows directly.

Symptom B, my own module marcofgv/freelancer-com 0.8.4, installed from the marketplace:

  • cmd_20260825T180805Z_search_projects_..._failed_with_receipt_0006.json"note": "validation failed: unknown field: query; unknown field: min_budget; unknown field: limit", input_fields: ["limit","min_budget","query"], external_api_touched: false.
  • v1.5.8's validate against that exact 0.8.4 manifest reproduces the string byte-for-byte and in order: (False, ['unknown field: query', 'unknown field: min_budget', 'unknown field: limit']).
  • Inversion, same code: validate(search_projects, {})(True, []). And place_bid (mode: write_requires_approval, risk: high, four required fields) → (True, []). That empty payload is what would reach the approval card. Nothing moves money — but only because my handler happens to open with a 100-character description guard that raises first. That is an accident of my module, not a station property, and I did not run place_bid to confirm it.
  • Natural experiment, local times: the command fails at 15:07-15:08; I hand-flatten the manifest at 15:39; the identical payload (payload_hash sha256:cef4b8f7…, same idempotency_key) executes cleanly at 16:12 (cmd_20260825T191227Z_..._executed_0012.json, result_status: executed, real API output). Only the schema shape changed.

Publish-time lint does not catch either shape: my 0.8.4 bundle carrying JSON Schema was accepted and installed from the marketplace, and the three modules above are live with integer/boolean. I did not locate a lint rule either way — this is evidence lint did not reject them, not proof no rule exists.

Disclosure: Symptom B is my own listing's bug too. marcofgv/freelancer-com 0.8.4 shipped JSON Schema on all 60 commands, on the strength of that FAQ line. I flattened my installed copy by hand to unblock myself, which is why my later receipts are green. I overwrote that copy, so I can no longer re-read the pre-edit installed manifest; the repro uses the signed 0.8.4 bundle from my source tree instead.

Impact

Symptom A is the wide one. It hits the shape the FAQ recommends and nearly every module uses, and it fails only on the specific inputs typed integer/boolean — so a module looks healthy until an operator supplies that one field, and then the failure reads like the publisher's bug. As a consequence of the validator, an optional field typed integer can never be supplied; a required one would make the command unrunnable. It also puts MCP and the airlock in direct contradiction: tools/list advertises integer, the airlock refuses it.

Symptom B is narrower today, since few modules use JSON Schema — but for those that do, nothing runs, and the firewall inverts: the guarantee in validate_inputs' own docstring, that a malformed payload is "rejected HERE and never surfaced for human approval", does not hold. A zero-input payload reaches the human approval card on a risk: high command. Execution then fails inside the handler rather than doing damage, so I am not claiming a spend path — but a governance check the airlock advertises is silently not running.

Suggested fix

Both symptoms are one function's vocabulary. Smallest change, in approval_airlock.validate:

_TYPES = {"array": list, "string": str, "text": str, "object": dict,
          "number": (int, float), "integer": int, "boolean": bool}

py = _TYPES.get(t)
if t is None:
    ok = True
elif py is None:
    ok = False                       # unknown type name in the manifest
elif t == "boolean":
    ok = isinstance(v, bool)
else:
    ok = isinstance(v, py) and not isinstance(v, bool)

This keeps your existing bool-is-not-a-number guard (a True passed to a number field still fails) while giving boolean its own case, and it turns an unrecognised type name into an honest rejection instead of a silent one. That alone fixes Symptom A.

For Symptom B, normalize on the way into the registry so every consumer sees one shape — at routes/modules.py:1374:

cmd = dict(cmd, input_schema=_to_flat_specs(cmd.get("input_schema")))
cmdreg.COMMANDS.append(dict(cmd, wired=True))

where _to_flat_specs is the inverse of the normalizer you already own: if properties or type == "object" is at the top level, return {name: {**prop, "required": name in schema.get("required", [])} for name, prop in schema["properties"].items()}; otherwise return unchanged. That leaves validate untouched and makes "either shape works" true everywhere. If you would rather not touch the loader, do the same detection at approval_airlock.py:199.

Two smaller things in the same path, both optional:

  • command_registry.py:315, validate_inputs, does spec.get("required") with no isinstance(spec, dict) guard, so a JSON Schema manifest raises AttributeError: 'str' object has no attribute 'get' on the "type" key. I confirmed it raises. It is currently unreachable — I grepped v1.5.8 and the function has no callers — so this is latent, not live. Worth the guard approval_airlock.validate already carries (its comment dates that fix to 2026-08-13) before anything re-wires it.
  • routes/commands.py:328 and :537 do output, artifact = LOCAL_HANDLERS[cmd_id](inputs or {}, stamp), a bare 2-tuple unpack, while the workflow path calls _flatten_module_result (routes/modules.py:733), which accepts a tuple or a plain dict. A handler returning a dict dies with too many values to unpack (expected 2). I hit this on whoami (cmd_20260825T180804Z_whoami_..._failed_safely_0005.json, note execution failed: too many values to unpack (expected 2)). :328 is the read-only path and :537 the post-approval one; that receipt carries a bound approval, so it went through :537 — meaning the failure landed after the operator had already approved. To be accurate about how much it matters: I AST-checked every module installed here and all of them — sami666's four, shweta's zoho-crm — return 2-tuples from every command. Mine was the outlier, so the contract is real and widely followed. The gap is that the FAQ documents handler returns nowhere, and the two dispatch paths disagree on tolerance. Reusing _flatten_module_result at those two sites, or one line in the FAQ, closes it.

Workaround for other publishers, until this lands

  1. Type names: use only string, number, object, array in input_schema. Replace integer with number and text with string. There is no working spelling for boolean today — take it as a string "true"/"false", or as number 0/1, and coerce inside the handler.
  2. Shape: if you wrote real JSON Schema, flatten it — drop type and additionalProperties, lift each entry of properties to a top-level key, and set "required": true on each name listed in the required array:
{"type": "object", "additionalProperties": false,
 "properties": {"query": {"type": "string"}, "limit": {"type": "integer", "default": 10}},
 "required": ["query"]}

becomes

{"query": {"type": "string", "required": true},
 "limit": {"type": "number", "required": false, "default": 10}}

Note both fixes are needed — flattening while keeping "type": "integer" lands you straight on Symptom A.

  1. Returns: if your handlers return plain dicts, this is enough:
def _tuple_adapter(fn):
    def _w(inputs, context=None):
        r = fn(inputs, context)
        return r if isinstance(r, tuple) else (r, None)
    _w.__wrapped__ = fn
    return _w

for _n in ["cmd_one", "cmd_two"]:          # your command ids
    _f = globals().get(_n)
    if callable(_f) and not hasattr(_f, "__wrapped__"):
        globals()[_n] = _tuple_adapter(_f)

0 replies

Sign in to reply.